Compare commits
21 Commits
54eef73b74
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8819773ee | ||
|
|
0c1cf3989a | ||
|
|
658c508925 | ||
|
|
29d8aa4aae | ||
|
|
29940c299b | ||
|
|
105ff8507f | ||
|
|
c2a12a895a | ||
|
|
df1f358912 | ||
|
|
7a27190ffe | ||
|
|
070f1ce156 | ||
|
|
a92fd90f0d | ||
|
|
70665fadff | ||
|
|
96b3c796c5 | ||
|
|
50c1997e91 | ||
|
|
3716e5974f | ||
|
|
85e13314a2 | ||
|
|
20f3fe4f71 | ||
|
|
f336ae9687 | ||
|
|
76fef827c5 | ||
|
|
b7144d5903 | ||
|
|
3c9b8f5909 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -25,5 +25,11 @@ tmp/
|
||||
*.tmp
|
||||
*.log
|
||||
|
||||
# Aider — history files are personal/ephemeral; .aider.conf.yml is project config and IS tracked
|
||||
.aider.chat.history.md
|
||||
.aider.input.history
|
||||
.aider.llm.history
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
.aider*
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -22,7 +22,7 @@ Cortex_and_Inara_dev/
|
||||
main.py ← App entry point, router registration
|
||||
config.py ← All settings (pydantic-settings, reads .env)
|
||||
persona.py ← Two-level identity: user + persona, path resolution, ContextVars
|
||||
llm_client.py ← Claude CLI + Gemini CLI subprocess backends
|
||||
llm_client.py ← Claude CLI + Gemini CLI subprocess backends + Anthropic SDK direct
|
||||
orchestrator_engine.py ← Gemini API ReAct tool loop → Claude handoff
|
||||
context_loader.py ← Builds system prompt from persona files (tier 1–4)
|
||||
session_store.py ← In-memory + file session persistence
|
||||
@@ -45,12 +45,15 @@ Cortex_and_Inara_dev/
|
||||
google_chat.py ← POST /webhook/google (Google Chat Add-on)
|
||||
ui.py ← Login/logout, /{user}/{persona} UI route, /api/personas
|
||||
onboarding.py ← /setup/{token} password step + /setup/persona creation
|
||||
settings.py ← /settings, /settings/notifications, /settings/integrations (admin)
|
||||
tools_settings.py ← /settings/tools
|
||||
crons.py ← /settings/crons — Schedules web UI (list/add/edit/toggle/remove)
|
||||
tools/
|
||||
__init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher)
|
||||
web.py ← DuckDuckGo web_search tool
|
||||
scratch.py ← Scratchpad tools (scratch_read/write/append/clear)
|
||||
tasks.py ← Personal task management (task_create/list/update/complete)
|
||||
cron.py ← Scheduled job tools (cron_list/add/remove/toggle)
|
||||
cron.py ← Scheduled job tools (cron_list/add/remove/toggle); 5 types; hourly/daily/weekly/monthly/yearly schedules
|
||||
system.py ← Local machine tools (claude_allow_dir)
|
||||
tests/ ← pytest test suite (80 tests)
|
||||
static/ ← Single-page web UI (index.html, style.css, app.js)
|
||||
@@ -136,9 +139,10 @@ http://localhost:8000/docs
|
||||
- **Orchestrated tasks** go to `POST /orchestrate` — returns a job_id, result is polled
|
||||
|
||||
### LLM Backends
|
||||
- `llm_client.py` manages Claude CLI (`claude --print`) and Gemini CLI (`gemini -p`) subprocesses
|
||||
- `llm_client.py` manages Claude CLI (`claude --print`), Gemini CLI (`gemini -p`), and Anthropic SDK (`anthropic_api` type) subprocesses/calls
|
||||
- `orchestrator_engine.py` uses the Gemini **API** (google-genai SDK) — completely separate from the Gemini CLI
|
||||
- Claude OAuth token is read live from `~/.claude/.credentials.json` (never rely on stale env var)
|
||||
- `anthropic_api` backend: user-configured API key from `providers.anthropic.credentials` in `model_registry.json` — uses `anthropic.AsyncAnthropic`
|
||||
|
||||
### Tool Strategy
|
||||
- Orchestrator tools live in `cortex/tools/` — separate from the `ae_*` MCP tools
|
||||
@@ -256,7 +260,7 @@ clearly asked for a directory to be unblocked.
|
||||
|
||||
---
|
||||
|
||||
## Current State (2026-05-09)
|
||||
## Current State (2026-05-12)
|
||||
|
||||
Cortex is running and stable. All channels are live:
|
||||
|
||||
@@ -272,20 +276,25 @@ Cortex is running and stable. All channels are live:
|
||||
| Token usage tracking | ✅ Live | Per-user `home/{user}/usage.json`; summary in Settings |
|
||||
| Web push | ✅ Live | VAPID push notifications; `web_push` tool; subscribe via ☰ menu |
|
||||
| Proactive notifications | ✅ Live | Daily reminder check (09:00); distill/cron completions; `GET /settings/notifications` dedicated page |
|
||||
| Proactive cron | ✅ Live | 5 job types: `remind`, `note`, `message`, `brief`, `task` (full orchestrator loop); monthly/yearly schedule formats; HA inbound webhook tools toggle |
|
||||
| Schedules web UI | ✅ Live | `/settings/crons` — list, add, edit, pause/resume, delete scheduled jobs |
|
||||
|
||||
Active users: scott (inara), holly (tina), brian (wintermute)
|
||||
|
||||
**58 orchestrator tools** across 15 domain modules:
|
||||
**69 orchestrator tools** across 17 domain modules:
|
||||
web_search/http_fetch/web_read/http_post,
|
||||
project_file_read/list + file_stat/grep/syntax_check (project-scoped),
|
||||
project_file_read/list + file_stat/grep/diff/syntax_check (project-scoped),
|
||||
file_read/list/write/session_read/session_search (system-scoped, admin),
|
||||
git_status/git_log/git_diff (read-only git inspection, project-scoped),
|
||||
shell_exec/claude_allow_dir,
|
||||
cortex_restart/logs/status/update,
|
||||
task_list/create/update/complete, cron_list/add/remove/toggle,
|
||||
reminders_add/list/remove/clear, scratch_read/write/append/clear,
|
||||
web_push/email_send/nc_talk_send/nc_talk_history,
|
||||
ae_journal_list/search/entries_list/entry_read/create/update/disable/append/prepend,
|
||||
ae_task_list, agent_notes_read/write/append/clear, spawn_agent,
|
||||
ae_task_list, ae_db_query/describe/show_view (SELECT-only MariaDB access, admin; disable requires confirm),
|
||||
agent_notes_read/write/append/clear, spawn_agent/aider_run (admin; aider_run requires confirm),
|
||||
agent_status/agent_list (user-level)/agent_cancel (admin, confirm-required),
|
||||
ha_get_state/ha_get_states/ha_call_service.
|
||||
|
||||
Each tool has a `TOOL_RISK` rating (low/medium/high). Configure access at `/settings/tools`
|
||||
|
||||
@@ -93,6 +93,18 @@ AE_API_KEY=
|
||||
AE_ACCOUNT_ID=
|
||||
AE_API_TIMEOUT=15
|
||||
|
||||
# ── Aether MariaDB (direct — SELECT-only via ae_db_query/describe/show_view tools) ─
|
||||
# Configured per-user in home/{username}/channels.json — NOT in .env.
|
||||
# Add this block to the user's channels.json to enable the tools:
|
||||
#
|
||||
# "aether_db": {
|
||||
# "host": "192.168.64.5",
|
||||
# "port": 3306,
|
||||
# "name": "aether_dev",
|
||||
# "user": "aether_dev",
|
||||
# "password": "..."
|
||||
# }
|
||||
|
||||
# ── Distillation schedule ────────────────────────────────────────────────────
|
||||
SCHEDULER_TIMEZONE=America/New_York
|
||||
AUTO_DISTILL=true
|
||||
|
||||
158
cortex/agent_manager.py
Normal file
158
cortex/agent_manager.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Agent lifecycle manager — registry for background spawn_agent and aider_run tasks.
|
||||
|
||||
Tracks running and recently completed agents in-process. On completion, fires
|
||||
notification.notify() if notify=True (same channel used by reminders and cron jobs).
|
||||
|
||||
Records are kept for 24 hours after completion, then pruned on next registration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRUNE_AFTER = timedelta(hours=24)
|
||||
_RESULT_PREVIEW_CHARS = 500
|
||||
_TASK_PREVIEW_CHARS = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRecord:
|
||||
agent_id: str
|
||||
level: int # 1 = persona, 2 = specialized sub-agent, 3 = support agent
|
||||
role: str # e.g. "coder", "research", "chat"
|
||||
task: str # first _TASK_PREVIEW_CHARS of the task
|
||||
status: str # running / done / failed / cancelled / timeout
|
||||
started: datetime
|
||||
user: str
|
||||
parent_id: str | None = None # agent_id of the spawner (lineage tracking)
|
||||
finished: datetime | None = None
|
||||
result: str | None = None # first _RESULT_PREVIEW_CHARS on completion
|
||||
notify: bool = False # push notification on completion
|
||||
_task_ref: "asyncio.Task | None" = field(default=None, repr=False)
|
||||
|
||||
|
||||
# Module-level registry — in-process only, not persisted across restarts.
|
||||
_agents: dict[str, AgentRecord] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def register(
|
||||
user: str,
|
||||
role: str,
|
||||
task: str,
|
||||
level: int = 2,
|
||||
parent_id: str | None = None,
|
||||
notify: bool = False,
|
||||
) -> AgentRecord:
|
||||
"""Create and register a new running agent. Returns the record (agent_id is set)."""
|
||||
agent_id = str(uuid.uuid4())
|
||||
rec = AgentRecord(
|
||||
agent_id=agent_id,
|
||||
level=level,
|
||||
role=role,
|
||||
task=task[:_TASK_PREVIEW_CHARS],
|
||||
status="running",
|
||||
started=datetime.now(),
|
||||
user=user,
|
||||
parent_id=parent_id,
|
||||
notify=notify,
|
||||
)
|
||||
async with _lock:
|
||||
_prune_locked()
|
||||
_agents[agent_id] = rec
|
||||
logger.info(
|
||||
"agent_manager: registered %s role=%s level=%d user=%s task=%.60s",
|
||||
agent_id[:8], role, level, user, task,
|
||||
)
|
||||
return rec
|
||||
|
||||
|
||||
def set_task_ref(agent_id: str, task_ref: "asyncio.Task") -> None:
|
||||
"""Store the asyncio.Task reference so it can be cancelled later.
|
||||
|
||||
Call immediately after asyncio.create_task() — before the event loop yields.
|
||||
"""
|
||||
rec = _agents.get(agent_id)
|
||||
if rec:
|
||||
rec._task_ref = task_ref
|
||||
|
||||
|
||||
async def finish(agent_id: str, result: str, status: str = "done") -> None:
|
||||
"""Mark an agent complete, store the result, and notify the user if requested."""
|
||||
async with _lock:
|
||||
rec = _agents.get(agent_id)
|
||||
if not rec:
|
||||
return
|
||||
rec.status = status
|
||||
rec.finished = datetime.now()
|
||||
rec.result = (result or "")[:_RESULT_PREVIEW_CHARS]
|
||||
|
||||
logger.info("agent_manager: finished %s status=%s", agent_id[:8], status)
|
||||
|
||||
if rec.notify and status != "cancelled":
|
||||
try:
|
||||
from notification import notify as _notify
|
||||
elapsed = int((rec.finished - rec.started).total_seconds())
|
||||
emoji = "✅" if status == "done" else "⚠️"
|
||||
preview = (rec.result or "(no output)")[:200]
|
||||
msg = f"{emoji} Agent done [{rec.role}, {elapsed}s]: {preview}"
|
||||
await _notify(rec.user, msg)
|
||||
except Exception as e:
|
||||
logger.warning("agent_manager: notification failed for %s: %s", agent_id[:8], e)
|
||||
|
||||
|
||||
async def cancel_agent(agent_id: str, user: str) -> str:
|
||||
"""Cancel a running background agent. Returns a human-readable status message."""
|
||||
async with _lock:
|
||||
rec = _agents.get(agent_id)
|
||||
if not rec:
|
||||
return f"No agent found: {agent_id}"
|
||||
if rec.user != user:
|
||||
return "Access denied."
|
||||
if rec.status != "running":
|
||||
return f"Agent {agent_id[:8]}… is already {rec.status}."
|
||||
task_ref = rec._task_ref
|
||||
rec.status = "cancelled"
|
||||
rec.finished = datetime.now()
|
||||
|
||||
if task_ref and not task_ref.done():
|
||||
task_ref.cancel()
|
||||
|
||||
logger.info("agent_manager: cancelled %s by user=%s", agent_id[:8], user)
|
||||
return f"Agent {agent_id[:8]}… cancelled."
|
||||
|
||||
|
||||
def get(agent_id: str) -> AgentRecord | None:
|
||||
"""Look up an agent record by ID."""
|
||||
return _agents.get(agent_id)
|
||||
|
||||
|
||||
def list_agents(user: str, status: str | None = None, limit: int = 10) -> list[AgentRecord]:
|
||||
"""Return recent agents for a user, newest first.
|
||||
|
||||
Does not acquire the lock — safe for read-only listing (Python dict iteration is
|
||||
thread-safe for reads; we don't care about racing with a concurrent registration).
|
||||
"""
|
||||
records = [r for r in _agents.values() if r.user == user]
|
||||
if status:
|
||||
records = [r for r in records if r.status == status]
|
||||
records.sort(key=lambda r: r.started, reverse=True)
|
||||
return records[:limit]
|
||||
|
||||
|
||||
def _prune_locked() -> None:
|
||||
"""Remove completed agents older than _PRUNE_AFTER. Must be called inside _lock."""
|
||||
cutoff = datetime.now() - _PRUNE_AFTER
|
||||
stale = [
|
||||
aid for aid, r in _agents.items()
|
||||
if r.status != "running" and r.finished and r.finished < cutoff
|
||||
]
|
||||
for aid in stale:
|
||||
del _agents[aid]
|
||||
if stale:
|
||||
logger.debug("agent_manager: pruned %d stale records", len(stale))
|
||||
@@ -10,9 +10,9 @@ Job schema:
|
||||
"id": "c_abc123",
|
||||
"label": "Human-readable name",
|
||||
"schedule": "daily:09:00", # see parse_schedule() for all formats
|
||||
"type": "remind" | "note" | "message" | "brief",
|
||||
"type": "remind" | "note" | "message" | "brief" | "task",
|
||||
"payload": "Text or prompt when the job fires",
|
||||
"channel": null | "nextcloud" | "google_chat", # for message/brief types
|
||||
"channel": null | "nextcloud" | "google_chat", # for message/brief/task types
|
||||
"enabled": true,
|
||||
"created_at": "ISO 8601",
|
||||
"last_run": null | "ISO 8601"
|
||||
@@ -21,9 +21,14 @@ Job schema:
|
||||
Job types:
|
||||
remind → appends to REMINDERS.md (auto-loaded into context at tier 2+)
|
||||
note → appends to SCRATCH.md (read on demand via scratch_read)
|
||||
message → sends payload as-is to NC Talk notification_room
|
||||
brief → runs LLM with payload as the prompt, sends response to NC Talk
|
||||
message → sends payload as-is to notification channel
|
||||
brief → calls LLM (no tools) with payload as prompt, sends response
|
||||
(good for morning briefings, summaries, proactive check-ins)
|
||||
task → runs full orchestrator tool loop with payload as the user request,
|
||||
sends Claude's response to notification channel
|
||||
(good for agentic scheduled work: research, file updates, checks)
|
||||
Tools that require confirmation are skipped — pre-approve them
|
||||
in Settings → Tools to allow them in scheduled tasks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -80,11 +85,16 @@ def parse_schedule(schedule: str) -> dict:
|
||||
Convert a human schedule string to APScheduler cron kwargs.
|
||||
|
||||
Formats:
|
||||
"hourly" → every hour at :00
|
||||
"daily" → every day at 09:00
|
||||
"daily:HH:MM" → every day at HH:MM
|
||||
"weekly:DOW" → every DOW at 09:00
|
||||
"weekly:DOW:HH:MM" → every DOW at HH:MM
|
||||
"hourly" → every hour at :00
|
||||
"daily" → every day at 09:00
|
||||
"daily:HH:MM" → every day at HH:MM
|
||||
"weekly:DOW" → every DOW at 09:00
|
||||
"weekly:DOW:HH:MM" → every DOW at HH:MM
|
||||
"monthly" → 1st of every month at 09:00
|
||||
"monthly:DD" → day DD of every month at 09:00
|
||||
"monthly:DD:HH:MM" → day DD of every month at HH:MM
|
||||
"yearly:MM:DD" → every year on MM/DD at 09:00 (birthdays, anniversaries)
|
||||
"yearly:MM:DD:HH:MM" → every year on MM/DD at HH:MM
|
||||
"""
|
||||
s = schedule.strip().lower()
|
||||
|
||||
@@ -112,9 +122,37 @@ def parse_schedule(schedule: str) -> dict:
|
||||
h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE
|
||||
return {"day_of_week": dow, "hour": h, "minute": m}
|
||||
|
||||
if s.startswith("monthly"):
|
||||
rest = s[7:].lstrip(":")
|
||||
if not rest:
|
||||
return {"day": 1, "hour": _DEFAULT_HOUR, "minute": _DEFAULT_MINUTE}
|
||||
parts = rest.split(":")
|
||||
day = _parse_day(parts[0], schedule)
|
||||
if len(parts) == 3:
|
||||
h, m = _parse_hhmm(f"{parts[1]}:{parts[2]}", schedule)
|
||||
else:
|
||||
h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE
|
||||
return {"day": day, "hour": h, "minute": m}
|
||||
|
||||
if s.startswith("yearly:"):
|
||||
rest = s[7:].split(":")
|
||||
if len(rest) < 2:
|
||||
raise ValueError(
|
||||
f"yearly requires at least MM:DD in {schedule!r}. "
|
||||
f"Example: yearly:03:15 or yearly:03:15:09:00"
|
||||
)
|
||||
month = _parse_month(rest[0], schedule)
|
||||
day = _parse_day(rest[1], schedule)
|
||||
if len(rest) == 4:
|
||||
h, m = _parse_hhmm(f"{rest[2]}:{rest[3]}", schedule)
|
||||
else:
|
||||
h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE
|
||||
return {"month": month, "day": day, "hour": h, "minute": m}
|
||||
|
||||
raise ValueError(
|
||||
f"Unrecognised schedule {schedule!r}. "
|
||||
f"Valid formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM"
|
||||
f"Valid formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM | "
|
||||
f"monthly | monthly:DD | monthly:DD:HH:MM | yearly:MM:DD | yearly:MM:DD:HH:MM"
|
||||
)
|
||||
|
||||
|
||||
@@ -125,6 +163,26 @@ def _parse_hhmm(s: str, original: str) -> tuple[int, int]:
|
||||
return int(parts[0]), int(parts[1])
|
||||
|
||||
|
||||
def _parse_day(s: str, original: str) -> int:
|
||||
try:
|
||||
d = int(s)
|
||||
except ValueError:
|
||||
raise ValueError(f"Expected day number (1–31) in {original!r}, got {s!r}")
|
||||
if not 1 <= d <= 31:
|
||||
raise ValueError(f"Day must be 1–31 in {original!r}, got {d}")
|
||||
return d
|
||||
|
||||
|
||||
def _parse_month(s: str, original: str) -> int:
|
||||
try:
|
||||
m = int(s)
|
||||
except ValueError:
|
||||
raise ValueError(f"Expected month number (1–12) in {original!r}, got {s!r}")
|
||||
if not 1 <= m <= 12:
|
||||
raise ValueError(f"Month must be 1–12 in {original!r}, got {m}")
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -188,6 +246,55 @@ async def run_job(job: dict) -> None:
|
||||
except Exception as e:
|
||||
logger.error("cron [brief] LLM error for %s: %s", label, e)
|
||||
|
||||
elif job_type == "task":
|
||||
# Run the full orchestrator tool loop, send Claude's response to the
|
||||
# notification channel. Tools that require confirmation are skipped in
|
||||
# cron context — the user is notified to pre-approve them.
|
||||
from orchestrator_engine import run as _orch_run
|
||||
from context_loader import load_context
|
||||
from notification import notify
|
||||
from persona import set_context
|
||||
from auth_utils import get_user_gemini_key, get_tool_policy, get_risk_policy
|
||||
from config import settings as _s
|
||||
|
||||
username = job.get("user") or _s.user_name.lower()
|
||||
persona_nm = job.get("persona") or _s.agent_name.lower()
|
||||
channel = job.get("channel") or None
|
||||
set_context(username, persona_nm)
|
||||
|
||||
system_prompt = load_context(2)
|
||||
policy = get_tool_policy(username)
|
||||
max_risk, whitelist, blacklist = get_risk_policy(username)
|
||||
gemini_key = get_user_gemini_key(username)
|
||||
|
||||
try:
|
||||
result = await _orch_run(
|
||||
task=payload,
|
||||
system_prompt=system_prompt,
|
||||
gemini_api_key=gemini_key,
|
||||
respond_with_claude=True,
|
||||
confirm_allow=set(policy.get("allow") or []),
|
||||
confirm_deny=set(policy.get("deny") or []),
|
||||
max_risk=max_risk,
|
||||
risk_whitelist=whitelist,
|
||||
risk_blacklist=blacklist,
|
||||
)
|
||||
if result.checkpoint:
|
||||
tool_name = (result.checkpoint.pending_calls[0].name
|
||||
if result.checkpoint.pending_calls else "unknown tool")
|
||||
msg = (
|
||||
f"Scheduled task '{label}' paused — "
|
||||
f"'{tool_name}' requires confirmation. "
|
||||
"Pre-approve it in Settings → Tools to allow it in scheduled tasks."
|
||||
)
|
||||
await notify(username, msg, channel=channel)
|
||||
logger.warning("cron [task] %s: confirmation required for %s", label, tool_name)
|
||||
else:
|
||||
await notify(username, result.response, channel=channel)
|
||||
logger.info("cron [task] completed via %s: %s", result.backend, label)
|
||||
except Exception as e:
|
||||
logger.error("cron [task] error for %s: %s", label, e)
|
||||
|
||||
else:
|
||||
logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id"))
|
||||
return
|
||||
|
||||
@@ -33,15 +33,16 @@ async def cleanup() -> None:
|
||||
|
||||
# Map from registry model type → dispatch function key
|
||||
_TYPE_TO_BACKEND = {
|
||||
"claude_cli": "claude",
|
||||
"gemini_cli": "gemini",
|
||||
"gemini_api": "gemini", # gemini_api falls back to CLI in this context
|
||||
"local_openai": "local",
|
||||
"claude_cli": "claude",
|
||||
"gemini_cli": "gemini",
|
||||
"gemini_api": "gemini", # gemini_api falls back to CLI in this context
|
||||
"local_openai": "local",
|
||||
"anthropic_api": "anthropic_api",
|
||||
}
|
||||
|
||||
# Explicit UI toggle values (kept for backward compat)
|
||||
_EXPLICIT_BACKENDS = ("claude", "gemini", "local")
|
||||
_FALLBACK = {"claude": "gemini", "gemini": "claude", "local": "claude"}
|
||||
_FALLBACK = {"claude": "gemini", "gemini": "claude", "local": "claude", "anthropic_api": "claude"}
|
||||
|
||||
|
||||
async def complete(
|
||||
@@ -51,6 +52,7 @@ async def complete(
|
||||
role: str = "chat",
|
||||
slot: str | None = None,
|
||||
max_tokens: int = 2048,
|
||||
attachment: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Returns (response_text, actual_backend_used).
|
||||
@@ -96,7 +98,7 @@ async def complete(
|
||||
fallback = _FALLBACK.get(primary, "claude")
|
||||
|
||||
try:
|
||||
response = await _dispatch(primary, system_prompt, messages, resolved_cfg)
|
||||
response = await _dispatch(primary, system_prompt, messages, resolved_cfg, attachment=attachment)
|
||||
return response, primary
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
@@ -116,11 +118,14 @@ async def _dispatch(
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model_cfg: dict | None,
|
||||
attachment: dict | None = None,
|
||||
) -> str:
|
||||
if backend == "gemini":
|
||||
return await _gemini(system_prompt, messages)
|
||||
if backend == "local":
|
||||
return await _local(system_prompt, messages, model_cfg)
|
||||
return await _local(system_prompt, messages, model_cfg, attachment=attachment)
|
||||
if backend == "anthropic_api":
|
||||
return await _anthropic_api(system_prompt, messages, model_cfg)
|
||||
return await _claude(system_prompt, messages, model_cfg)
|
||||
|
||||
|
||||
@@ -166,11 +171,17 @@ async def _claude(system_prompt: str, messages: list[dict], model_cfg: dict | No
|
||||
return await _run(cmd, timeout=settings.timeout_claude, env=env)
|
||||
|
||||
|
||||
async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | None = None) -> str:
|
||||
async def _local(
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model_cfg: dict | None = None,
|
||||
attachment: dict | None = None,
|
||||
) -> str:
|
||||
"""OpenAI-compatible backend — Open WebUI / Ollama.
|
||||
|
||||
model_cfg is pre-resolved by complete() via model_registry.
|
||||
Falls back to registry lookup if not provided.
|
||||
attachment: optional image dict {filename, mime_type, data} for vision calls.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
@@ -200,8 +211,20 @@ async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | Non
|
||||
msgs: list[dict] = []
|
||||
if system_prompt:
|
||||
msgs.append({"role": "system", "content": system_prompt})
|
||||
# Strip any non-standard metadata fields before sending to the API
|
||||
msgs.extend({"role": m["role"], "content": m["content"]} for m in messages)
|
||||
|
||||
# Build message list; inject image into the last user message when present.
|
||||
for i, m in enumerate(messages):
|
||||
is_last = (i == len(messages) - 1)
|
||||
if is_last and m["role"] == "user" and attachment:
|
||||
content: list[dict] = [{"type": "text", "text": m["content"]}]
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": attachment["data"]},
|
||||
})
|
||||
msgs.append({"role": "user", "content": content})
|
||||
else:
|
||||
# Strip non-standard metadata fields before sending to the API
|
||||
msgs.append({"role": m["role"], "content": m["content"]})
|
||||
|
||||
url = api_url.rstrip("/") + chat_path
|
||||
headers: dict[str, str] = {}
|
||||
@@ -234,6 +257,51 @@ async def _local(system_prompt: str, messages: list[dict], model_cfg: dict | Non
|
||||
return text.strip()
|
||||
|
||||
|
||||
async def _anthropic_api(system_prompt: str, messages: list[dict], model_cfg: dict | None) -> str:
|
||||
"""Direct Anthropic API backend using the anthropic SDK."""
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
raise RuntimeError("anthropic SDK not installed — run: pip install 'anthropic>=0.40.0'")
|
||||
|
||||
cfg = model_cfg or {}
|
||||
api_key = cfg.get("api_key", "")
|
||||
model_name = cfg.get("model_name") or settings.default_model
|
||||
|
||||
if not api_key:
|
||||
raise RuntimeError("No Anthropic API key — add one at /settings/models")
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=api_key)
|
||||
|
||||
msgs = [{"role": m["role"], "content": m["content"]} for m in messages]
|
||||
kwargs: dict = {
|
||||
"model": model_name,
|
||||
"max_tokens": 4096,
|
||||
"messages": msgs,
|
||||
}
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
resp = await client.messages.create(**kwargs)
|
||||
|
||||
text = resp.content[0].text if resp.content else ""
|
||||
if not text.strip():
|
||||
raise RuntimeError("Anthropic API returned an empty response")
|
||||
|
||||
if resp.usage:
|
||||
import usage_tracker
|
||||
from persona import _user
|
||||
asyncio.create_task(usage_tracker.record(
|
||||
username=_user.get(),
|
||||
backend="anthropic_api",
|
||||
model_name=model_name,
|
||||
prompt_tokens=resp.usage.input_tokens,
|
||||
completion_tokens=resp.usage.output_tokens,
|
||||
))
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
async def _gemini(system_prompt: str, messages: list[dict]) -> str:
|
||||
# Gemini CLI spawns MCP child processes that keep stdout pipes open after responding.
|
||||
# start_new_session=True puts the whole tree in its own process group so
|
||||
|
||||
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
|
||||
from config import settings
|
||||
from auth_middleware import SessionAuthMiddleware
|
||||
from routers import chat, google_chat, nextcloud_talk, homeassistant, files, distill, auth, orchestrator
|
||||
from routers import ui, onboarding, settings, tools_settings, help, auth_google, local_llm, push, audit, usage
|
||||
from routers import ui, onboarding, settings, tools_settings, help, auth_google, local_llm, push, audit, usage, crons
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -53,19 +53,21 @@ app.include_router(onboarding.router)
|
||||
app.include_router(settings.router)
|
||||
app.include_router(tools_settings.router)
|
||||
app.include_router(local_llm.router)
|
||||
app.include_router(crons.router)
|
||||
|
||||
# Help page
|
||||
app.include_router(help.router)
|
||||
|
||||
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
|
||||
app.include_router(ui.router)
|
||||
|
||||
|
||||
# Health check — must be before ui.router so /{username} catch-all doesn't swallow it.
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
|
||||
app.include_router(ui.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
|
||||
@@ -57,6 +57,7 @@ Types:
|
||||
"gemini_cli" — Gemini CLI subprocess
|
||||
"gemini_api" — Gemini API (google-genai SDK); account_id → api_key from providers.google
|
||||
"local_openai" — OpenAI-compatible endpoint; host_id → api_url/api_key from hosts[]
|
||||
"anthropic_api" — Anthropic SDK direct; credential_id → api_key from providers.anthropic.credentials
|
||||
|
||||
Built-in model IDs (always resolvable without a registry entry):
|
||||
"claude_cli" — resolves to the default Claude CLI model
|
||||
@@ -80,6 +81,24 @@ from config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Role-level tool defaults ───────────────────────────────────────────────────
|
||||
# Applied when a user hasn't configured a custom tool list for a role.
|
||||
# None = no restriction (all accessible tools); [] = no tools (pure text processing).
|
||||
# "chat" is intentionally absent: the /chat endpoint never sends tool schemas anyway,
|
||||
# and the orchestrator uses chat_role="chat" as its default — restricting it here
|
||||
# would block all tools from every default orchestration request.
|
||||
# "orchestrator" is intentionally absent — Phase 2 keyword routing narrows it per message.
|
||||
ROLE_DEFAULT_TOOLS: dict[str, list[str] | None] = {
|
||||
"distill": [], # pure text processing — no tools needed
|
||||
"research": ["web_search", "web_read", "http_fetch"],
|
||||
"coder": [
|
||||
"project_file_read", "project_file_list", "file_stat", "file_grep",
|
||||
"file_diff", "file_syntax_check", "file_read", "file_list", "file_write",
|
||||
"git_status", "git_log", "git_diff", "shell_exec",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Provider model catalogs ───────────────────────────────────────────────────
|
||||
# Server-side defaults. Update here when providers release new models.
|
||||
# Users can add entries via the settings UI (Phase 2).
|
||||
@@ -105,6 +124,18 @@ GOOGLE_CATALOG: list[dict] = [
|
||||
{"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash-Lite (preview)", "context_k": 1000},
|
||||
]
|
||||
|
||||
# Known OpenAI-compatible cloud inference services.
|
||||
# All use host_type "openai" (/chat/completions + /models paths).
|
||||
CLOUD_API_CATALOG: list[dict] = [
|
||||
{"id": "openrouter", "label": "OpenRouter", "api_url": "https://openrouter.ai/api/v1"},
|
||||
{"id": "openai", "label": "OpenAI", "api_url": "https://api.openai.com/v1"},
|
||||
{"id": "groq", "label": "Groq", "api_url": "https://api.groq.com/openai/v1"},
|
||||
{"id": "xai", "label": "X.ai / Grok", "api_url": "https://api.x.ai/v1"},
|
||||
{"id": "together", "label": "Together.ai", "api_url": "https://api.together.xyz/v1"},
|
||||
{"id": "fireworks", "label": "Fireworks.ai", "api_url": "https://api.fireworks.ai/inference/v1"},
|
||||
{"id": "custom", "label": "Custom", "api_url": ""},
|
||||
]
|
||||
|
||||
|
||||
# ── Built-in model definitions ────────────────────────────────────────────────
|
||||
|
||||
@@ -147,6 +178,8 @@ _ROLE_LAST_RESORT: dict[str, str] = {
|
||||
|
||||
PRIORITY_KEYS = ["primary", "backup_1", "backup_2", "backup_3", "backup_4"]
|
||||
|
||||
REQUIRED_ROLES: list[str] = ["chat", "orchestrator", "distill"]
|
||||
|
||||
|
||||
# ── Storage ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -353,6 +386,16 @@ def _resolve_model(registry: dict, model_id: str) -> dict | None:
|
||||
logger.warning("model %s references missing account_id %s", model_id, account_id)
|
||||
return dict(model)
|
||||
|
||||
if model_type == "anthropic_api":
|
||||
credential_id = model.get("credential_id")
|
||||
if credential_id:
|
||||
creds = registry.get("providers", {}).get("anthropic", {}).get("credentials", [])
|
||||
cred = next((c for c in creds if c["id"] == credential_id), None)
|
||||
if cred and cred.get("api_key"):
|
||||
return {**model, "api_key": cred["api_key"]}
|
||||
logger.warning("model %s references missing/keyless credential_id %s", model_id, credential_id)
|
||||
return dict(model)
|
||||
|
||||
if model_type == "claude_cli":
|
||||
return dict(model)
|
||||
|
||||
@@ -457,9 +500,16 @@ def get_role_config(username: str, role: str) -> dict:
|
||||
"""
|
||||
registry = _load(username)
|
||||
role_cfg = registry.get("roles", {}).get(role, {})
|
||||
user_tools = role_cfg.get("tools")
|
||||
if user_tools is None:
|
||||
# No user-configured list — fall back to system defaults for this role
|
||||
effective_tools: list[str] | None = ROLE_DEFAULT_TOOLS.get(role)
|
||||
else:
|
||||
# User has configured tools; preserve their setting (empty list → no restriction)
|
||||
effective_tools = user_tools or None
|
||||
return {
|
||||
"system_append": role_cfg.get("system_append", ""),
|
||||
"tools": role_cfg.get("tools") or None,
|
||||
"tools": effective_tools,
|
||||
"inject_datetime": role_cfg.get("inject_datetime", True),
|
||||
"inject_mode": role_cfg.get("inject_mode", True),
|
||||
}
|
||||
@@ -554,6 +604,8 @@ def get_catalog(provider: str, username: str | None = None) -> list[dict]:
|
||||
return list(ANTHROPIC_CATALOG)
|
||||
if provider == "google":
|
||||
return list(GOOGLE_CATALOG)
|
||||
if provider == "cloud":
|
||||
return list(CLOUD_API_CATALOG)
|
||||
return []
|
||||
|
||||
|
||||
@@ -606,6 +658,72 @@ def remove_google_account(username: str, account_id: str) -> bool:
|
||||
return len(data["providers"]["google"]["accounts"]) < before
|
||||
|
||||
|
||||
# ── Write API — Anthropic API keys ───────────────────────────────────────────
|
||||
|
||||
def get_anthropic_api_keys(username: str) -> list[dict]:
|
||||
"""Return Anthropic API key credentials (type='api_key') with key masked for display."""
|
||||
registry = _load(username)
|
||||
creds = registry.get("providers", {}).get("anthropic", {}).get("credentials", [])
|
||||
return [
|
||||
{
|
||||
"id": c["id"],
|
||||
"label": c.get("label", ""),
|
||||
"hint": (c.get("api_key") or "")[:8] + "…" if c.get("api_key") else "no key",
|
||||
}
|
||||
for c in creds
|
||||
if c.get("type") == "api_key"
|
||||
]
|
||||
|
||||
|
||||
def save_anthropic_api_key(username: str, key_id: str | None,
|
||||
label: str, api_key: str) -> str:
|
||||
"""Create or update an Anthropic API key credential. Returns the credential ID."""
|
||||
data = _load(username)
|
||||
creds = data["providers"]["anthropic"]["credentials"]
|
||||
|
||||
if key_id:
|
||||
for c in creds:
|
||||
if c["id"] == key_id and c.get("type") == "api_key":
|
||||
c["label"] = label.strip() or c.get("label", "API Key")
|
||||
if api_key.strip():
|
||||
c["api_key"] = api_key.strip()
|
||||
_save(username, data)
|
||||
return key_id
|
||||
|
||||
key_id = secrets.token_hex(4)
|
||||
creds.append({
|
||||
"id": key_id,
|
||||
"label": label.strip() or "API Key",
|
||||
"type": "api_key",
|
||||
"api_key": api_key.strip(),
|
||||
})
|
||||
_save(username, data)
|
||||
return key_id
|
||||
|
||||
|
||||
def remove_anthropic_api_key(username: str, key_id: str) -> bool:
|
||||
"""Remove an Anthropic API key credential. Clears model entries that reference it."""
|
||||
data = _load(username)
|
||||
creds = data["providers"]["anthropic"]["credentials"]
|
||||
before = len(creds)
|
||||
data["providers"]["anthropic"]["credentials"] = [
|
||||
c for c in creds if c["id"] != key_id
|
||||
]
|
||||
|
||||
removed_model_ids = {
|
||||
m["id"] for m in data.get("models", [])
|
||||
if m.get("credential_id") == key_id
|
||||
}
|
||||
data["models"] = [m for m in data.get("models", []) if m["id"] not in removed_model_ids]
|
||||
for role_cfg in data.get("roles", {}).values():
|
||||
for key in PRIORITY_KEYS:
|
||||
if role_cfg.get(key) in removed_model_ids:
|
||||
role_cfg[key] = None
|
||||
|
||||
_save(username, data)
|
||||
return len(data["providers"]["anthropic"]["credentials"]) < before
|
||||
|
||||
|
||||
# ── Write API — Hosts ─────────────────────────────────────────────────────────
|
||||
|
||||
def save_host(username: str, host_id: str | None,
|
||||
@@ -716,11 +834,19 @@ def save_cloud_model(username: str, model_id: str | None,
|
||||
|
||||
provider: "anthropic" | "google"
|
||||
account_id: Google only — references providers.google.accounts[].id
|
||||
credential_id: Anthropic only — e.g. "cli"
|
||||
credential_id: Anthropic only — "cli" for OAuth CLI, or a hex ID for an API key credential
|
||||
"""
|
||||
_TYPE = {"google": "gemini_api", "anthropic": "claude_cli"}
|
||||
entry_type = _TYPE.get(provider, "gemini_api")
|
||||
data = _load(username)
|
||||
|
||||
# Determine model type from credential (anthropic only)
|
||||
if provider == "anthropic":
|
||||
creds = data.get("providers", {}).get("anthropic", {}).get("credentials", [])
|
||||
cred = next((c for c in creds if c["id"] == credential_id), None) if credential_id else None
|
||||
entry_type = "anthropic_api" if (cred and cred.get("type") == "api_key") else "claude_cli"
|
||||
elif provider == "google":
|
||||
entry_type = "gemini_api"
|
||||
else:
|
||||
entry_type = "claude_cli"
|
||||
tags = tags or []
|
||||
|
||||
entry: dict = {
|
||||
@@ -766,6 +892,52 @@ def remove_model(username: str, model_id: str) -> bool:
|
||||
return len(data["models"]) < before
|
||||
|
||||
|
||||
def get_custom_roles(username: str) -> list[str]:
|
||||
"""
|
||||
Return the user's custom (non-required) roles.
|
||||
Falls back to config-defined roles minus required ones for migration.
|
||||
"""
|
||||
registry = _load(username)
|
||||
if "custom_roles" in registry:
|
||||
return [r for r in registry["custom_roles"] if r and r not in REQUIRED_ROLES]
|
||||
from config import settings as _cfg
|
||||
return [r for r in _cfg.get_defined_roles() if r not in REQUIRED_ROLES]
|
||||
|
||||
|
||||
def get_all_roles(username: str) -> list[str]:
|
||||
"""Return required roles followed by the user's custom roles."""
|
||||
return list(REQUIRED_ROLES) + get_custom_roles(username)
|
||||
|
||||
|
||||
def add_custom_role(username: str, role_name: str) -> bool:
|
||||
"""Add a custom role. Returns False if the name is invalid or already a required role."""
|
||||
role_name = role_name.strip().lower()
|
||||
if not role_name or role_name in REQUIRED_ROLES:
|
||||
return False
|
||||
data = _load(username)
|
||||
if "custom_roles" not in data:
|
||||
from config import settings as _cfg
|
||||
data["custom_roles"] = [r for r in _cfg.get_defined_roles() if r not in REQUIRED_ROLES]
|
||||
if role_name not in data["custom_roles"]:
|
||||
data["custom_roles"].append(role_name)
|
||||
_save(username, data)
|
||||
return True
|
||||
|
||||
|
||||
def remove_custom_role(username: str, role_name: str) -> bool:
|
||||
"""Remove a custom role. Required roles cannot be removed."""
|
||||
if role_name in REQUIRED_ROLES:
|
||||
return False
|
||||
data = _load(username)
|
||||
if "custom_roles" not in data:
|
||||
from config import settings as _cfg
|
||||
data["custom_roles"] = [r for r in _cfg.get_defined_roles() if r not in REQUIRED_ROLES]
|
||||
if role_name in data["custom_roles"]:
|
||||
data["custom_roles"].remove(role_name)
|
||||
_save(username, data)
|
||||
return True
|
||||
|
||||
|
||||
def set_role(username: str, role: str, priority: str, model_id: str | None) -> bool:
|
||||
"""
|
||||
Assign a model to a role priority slot.
|
||||
|
||||
@@ -25,7 +25,7 @@ from openai import AsyncOpenAI, APIConnectionError, APIStatusError
|
||||
|
||||
from config import settings
|
||||
from orchestrator_engine import OrchestrateCheckpoint, OrchestratorResult
|
||||
from tools import OPENAI_TOOL_SCHEMAS, call_tool, get_openai_tools_for_role, get_tools_for_role, CONFIRM_REQUIRED
|
||||
from tools import OPENAI_TOOL_SCHEMAS, call_tool, get_openai_tools_for_role, get_tools_for_role, CONFIRM_REQUIRED, narrow_tools_by_keywords
|
||||
import tool_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -76,8 +76,18 @@ async def run(
|
||||
_confirm_deny = frozenset(confirm_deny or ())
|
||||
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
|
||||
|
||||
# Keyword routing: narrow schemas to only what this message needs.
|
||||
# Also scans the last assistant turn so follow-ups like "yes, do that" inherit tool context.
|
||||
# Returns [] when no keywords match (zero tool overhead — model responds as plain chat).
|
||||
effective_tool_list = narrow_tools_by_keywords(task, tool_list, context_messages=session_messages)
|
||||
logger.info(
|
||||
"Keyword routing: %d tools active (role_tools=%s)",
|
||||
len(effective_tool_list),
|
||||
len(tool_list) if tool_list is not None else "all",
|
||||
)
|
||||
|
||||
client, model_name, active_tools = _build_client(
|
||||
model_cfg, user_role, tool_list,
|
||||
model_cfg, user_role, effective_tool_list,
|
||||
max_risk=max_risk, risk_whitelist=risk_whitelist, risk_blacklist=risk_blacklist,
|
||||
)
|
||||
tool_audit.set_context("openai", model_cfg.get("label") or model_name)
|
||||
@@ -104,7 +114,7 @@ async def run(
|
||||
model_cfg=model_cfg,
|
||||
respond_with_final=respond_with_final,
|
||||
user_role=user_role,
|
||||
tool_list=tool_list,
|
||||
tool_list=effective_tool_list,
|
||||
confirm_allow=_confirm_allow,
|
||||
confirm_deny=_confirm_deny,
|
||||
starting_round=0,
|
||||
@@ -198,13 +208,39 @@ async def resume(checkpoint: OrchestrateCheckpoint, confirmed: bool) -> Orchestr
|
||||
|
||||
|
||||
_CHARS_PER_TOKEN = 4
|
||||
# Fixed token overhead budget for sending 40 tool schemas per call
|
||||
_TOOL_SCHEMA_OVERHEAD = 3_000
|
||||
# Fixed token overhead budget per call (tool schemas excluded — cached separately)
|
||||
_TOOL_SCHEMA_OVERHEAD = 500
|
||||
# Chars to keep per truncated old tool result
|
||||
_TRUNC_RESULT_CHARS = 400
|
||||
# Always keep the last N tool-result messages uncompacted
|
||||
_KEEP_RECENT_TOOL_MSGS = 6 # ~2 rounds of 3 tools each
|
||||
|
||||
# Module-level schema cache: key = (user_role, sorted_tools, risk_params)
|
||||
# Bounded in practice — keyword routing produces at most ~30 distinct tool sets.
|
||||
_tool_schema_cache: dict[str, list[dict]] = {}
|
||||
|
||||
|
||||
def _get_cached_tools(
|
||||
user_role: str,
|
||||
tool_list: list[str] | None,
|
||||
max_risk: str | None = None,
|
||||
whitelist: list[str] | None = None,
|
||||
blacklist: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
key = "|".join([
|
||||
user_role,
|
||||
str(sorted(tool_list) if tool_list is not None else "all"),
|
||||
str(max_risk),
|
||||
str(sorted(whitelist) if whitelist else ""),
|
||||
str(sorted(blacklist) if blacklist else ""),
|
||||
])
|
||||
if key not in _tool_schema_cache:
|
||||
_tool_schema_cache[key] = get_openai_tools_for_role(
|
||||
user_role, tool_list,
|
||||
max_risk=max_risk, whitelist=whitelist, blacklist=blacklist,
|
||||
)
|
||||
return _tool_schema_cache[key]
|
||||
|
||||
|
||||
def _estimate_tokens(messages: list[dict]) -> int:
|
||||
total = sum(len(json.dumps(m)) for m in messages)
|
||||
@@ -448,7 +484,7 @@ def _build_client(
|
||||
if model_cfg.get("tools") is False:
|
||||
active_tools = []
|
||||
else:
|
||||
active_tools = get_openai_tools_for_role(
|
||||
active_tools = _get_cached_tools(
|
||||
user_role, tool_list,
|
||||
max_risk=max_risk, whitelist=risk_whitelist, blacklist=risk_blacklist,
|
||||
)
|
||||
|
||||
@@ -28,5 +28,8 @@ openai>=1.0.0
|
||||
# Web Push / VAPID — browser push notifications
|
||||
pywebpush>=2.0.0
|
||||
|
||||
# anthropic SDK not needed — using claude CLI subprocess for auth
|
||||
# anthropic>=0.40.0
|
||||
# MariaDB / MySQL connector — used by ae_db_query orchestrator tool
|
||||
pymysql>=1.1.0
|
||||
|
||||
# Anthropic SDK — direct API key backend (alternative to CLI OAuth)
|
||||
anthropic>=0.40.0
|
||||
|
||||
@@ -42,11 +42,18 @@ def _role_model_label(username: str, role: str, actual_backend: str) -> str:
|
||||
return _backend_label(actual_backend, username, role)
|
||||
|
||||
|
||||
class Attachment(BaseModel):
|
||||
filename: str
|
||||
mime_type: str
|
||||
data: str # base64 data URL for images (e.g. "data:image/png;base64,...")
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
session_id: str | None = None
|
||||
tier: int | None = None
|
||||
model: str | None = None # legacy backend override ("claude"|"gemini"|"local")
|
||||
slot: str | None = None # Phase 3: explicit slot ("primary"|"backup_1"|"backup_2")
|
||||
chat_role: str = "chat" # active role: "chat"|"coder"|"research"|"distill" etc.
|
||||
include_long: bool = True
|
||||
include_mid: bool = True
|
||||
@@ -54,6 +61,7 @@ class ChatRequest(BaseModel):
|
||||
off_record: bool = False # skip session log (in-memory context preserved)
|
||||
user: str = "scott"
|
||||
persona: str = "inara"
|
||||
attachment: Attachment | None = None # image attachment (text files injected client-side)
|
||||
|
||||
|
||||
class BackendRequest(BaseModel):
|
||||
@@ -102,6 +110,19 @@ async def _stream_chat(req: ChatRequest):
|
||||
mode="otr" if req.off_record else "chat",
|
||||
)
|
||||
history = load_session(session_id)
|
||||
|
||||
# req.message already contains the full user text:
|
||||
# - text files: client embedded content as a fenced code block
|
||||
# - images: client added "📎 filename.png" note; image data is in req.attachment
|
||||
# History always stores text only — base64 image data is never written to disk.
|
||||
llm_attachment: dict | None = None
|
||||
if req.attachment and req.attachment.mime_type.startswith("image/"):
|
||||
llm_attachment = {
|
||||
"filename": req.attachment.filename,
|
||||
"mime_type": req.attachment.mime_type,
|
||||
"data": req.attachment.data,
|
||||
}
|
||||
|
||||
history.append({"role": "user", "content": req.message, "off_record": req.off_record})
|
||||
|
||||
task = asyncio.create_task(complete(
|
||||
@@ -109,6 +130,8 @@ async def _stream_chat(req: ChatRequest):
|
||||
messages=history,
|
||||
model=req.model,
|
||||
role=req.chat_role,
|
||||
slot=req.slot,
|
||||
attachment=llm_attachment,
|
||||
))
|
||||
|
||||
try:
|
||||
@@ -124,7 +147,11 @@ async def _stream_chat(req: ChatRequest):
|
||||
|
||||
try:
|
||||
response_text, actual_backend = task.result()
|
||||
backend_label = _role_model_label(user, req.chat_role, actual_backend)
|
||||
if req.slot:
|
||||
slot_cfg = model_registry.get_model_for_slot(user, req.chat_role, req.slot)
|
||||
backend_label = (slot_cfg or {}).get("label") or _role_model_label(user, req.chat_role, actual_backend)
|
||||
else:
|
||||
backend_label = _role_model_label(user, req.chat_role, actual_backend)
|
||||
host = platform.node()
|
||||
history.append({
|
||||
"role": "assistant",
|
||||
@@ -203,6 +230,25 @@ def _local_model_info(request: Request) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _chat_slot_models(username: str) -> list[dict]:
|
||||
"""Return [{slot, label, type}] for each configured slot in the chat role, primary first."""
|
||||
registry = model_registry.get_registry(username)
|
||||
role_slots = registry.get("roles", {}).get("chat", {})
|
||||
result = []
|
||||
for slot_key in model_registry.PRIORITY_KEYS:
|
||||
model_id = role_slots.get(slot_key)
|
||||
if not model_id:
|
||||
continue
|
||||
resolved = model_registry._resolve_model(registry, model_id)
|
||||
if resolved:
|
||||
result.append({
|
||||
"slot": slot_key,
|
||||
"label": resolved.get("label") or resolved.get("model_name") or "",
|
||||
"type": resolved.get("type", ""),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _available_roles_for_toggle(username: str) -> list[dict]:
|
||||
"""Return roles with a primary model assigned (excluding orchestrator) for the UI toggle.
|
||||
|
||||
@@ -231,6 +277,7 @@ def _available_roles_for_toggle(username: str) -> list[dict]:
|
||||
@router.get("/backend")
|
||||
async def get_backend(request: Request) -> dict:
|
||||
username = _request_user(request)
|
||||
chat_models = _chat_slot_models(username) if username else []
|
||||
available_roles = _available_roles_for_toggle(username) if username else []
|
||||
p = settings.primary_backend
|
||||
|
||||
@@ -241,7 +288,8 @@ async def get_backend(request: Request) -> dict:
|
||||
orch_label = orch_cfg.get("label") or orch_cfg.get("model_name") or None
|
||||
|
||||
return {
|
||||
"available_roles": available_roles,
|
||||
"chat_models": chat_models, # Phase 3: [{slot, label, type}] for chat-role slots
|
||||
"available_roles": available_roles, # kept for banner + backward compat
|
||||
"orchestrator_model": orch_label,
|
||||
# Legacy fields kept for backward compat
|
||||
"primary": p,
|
||||
|
||||
479
cortex/routers/crons.py
Normal file
479
cortex/routers/crons.py
Normal file
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
Schedules web UI — GET/POST /settings/crons/*
|
||||
|
||||
Lets users view, add, toggle, and remove cron jobs without going through the AI.
|
||||
Cron data lives in home/{user}/persona/{persona}/CRONS.json.
|
||||
Scheduler registration mirrors what tools/cron.py does so changes take effect immediately.
|
||||
"""
|
||||
|
||||
import html as _html
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token
|
||||
from cron_runner import load_crons, save_crons, parse_schedule
|
||||
from persona import list_user_personas
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_STATIC = Path(__file__).parent.parent / "static"
|
||||
_LAST_PERSONA_COOKIE = "cx_last_persona"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_session_user(request: Request) -> str | None:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return decode_token(token)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def _preferred_persona(request: Request, username: str) -> str:
|
||||
names = list_user_personas(username)
|
||||
if not names:
|
||||
return ""
|
||||
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
|
||||
if cookie_val in names:
|
||||
return cookie_val
|
||||
return names[0]
|
||||
|
||||
|
||||
def _integrations_nav(username: str) -> str:
|
||||
from auth_utils import _read_auth
|
||||
role = _read_auth(username).get("role", "user")
|
||||
if role == "admin":
|
||||
return '<a href="/settings/integrations" class="nav-link">Integrations</a>'
|
||||
return ""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _short_id() -> str:
|
||||
return "c_" + secrets.token_urlsafe(6)
|
||||
|
||||
|
||||
def _scheduler_add(job: dict, sched_kwargs: dict) -> None:
|
||||
import asyncio
|
||||
try:
|
||||
import scheduler as sched_module
|
||||
from cron_runner import run_job
|
||||
s = sched_module.get_scheduler()
|
||||
if s and s.running:
|
||||
sched_id = f"{job['user']}:{job['persona']}:{job['id']}"
|
||||
s.add_job(
|
||||
lambda j=job: asyncio.ensure_future(run_job(j)),
|
||||
"cron",
|
||||
id=sched_id,
|
||||
replace_existing=True,
|
||||
**sched_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("scheduler_add failed: %s", e)
|
||||
|
||||
|
||||
def _scheduler_remove(job_id: str) -> None:
|
||||
try:
|
||||
import scheduler as sched_module
|
||||
s = sched_module.get_scheduler()
|
||||
if s and s.running:
|
||||
s.remove_job(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _scheduler_pause(job_id: str) -> None:
|
||||
try:
|
||||
import scheduler as sched_module
|
||||
s = sched_module.get_scheduler()
|
||||
if s and s.running:
|
||||
s.pause_job(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _scheduler_resume(job_id: str) -> None:
|
||||
try:
|
||||
import scheduler as sched_module
|
||||
s = sched_module.get_scheduler()
|
||||
if s and s.running:
|
||||
s.resume_job(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_TYPE_CLASS = {
|
||||
"remind": "badge-remind", "note": "badge-note", "message": "badge-message",
|
||||
"brief": "badge-brief", "task": "badge-task",
|
||||
}
|
||||
|
||||
|
||||
def _render_cron_list(username: str) -> str:
|
||||
personas = list_user_personas(username)
|
||||
if not personas:
|
||||
return '<div class="empty-state">No personas found.</div>'
|
||||
|
||||
all_empty = True
|
||||
html_parts: list[str] = []
|
||||
|
||||
for persona in personas:
|
||||
crons = load_crons(username, persona)
|
||||
if not crons:
|
||||
continue
|
||||
all_empty = False
|
||||
|
||||
rows = []
|
||||
for c in crons:
|
||||
cid = _html.escape(c["id"])
|
||||
label = _html.escape(c.get("label", ""))
|
||||
schedule = _html.escape(c.get("schedule", ""))
|
||||
job_type = _html.escape(c.get("type", ""))
|
||||
payload = _html.escape(c.get("payload", ""))
|
||||
enabled = c.get("enabled", True)
|
||||
last_run = (c.get("last_run") or "")[:10] or "never"
|
||||
pers_esc = _html.escape(persona)
|
||||
|
||||
type_class = _TYPE_CLASS.get(c.get("type", ""), "badge-note")
|
||||
status_cls = "badge-enabled" if enabled else "badge-paused"
|
||||
status_txt = "enabled" if enabled else "paused"
|
||||
toggle_txt = "Pause" if enabled else "Resume"
|
||||
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td>{label}</td>
|
||||
<td><code>{schedule}</code></td>
|
||||
<td><span class="badge {type_class}">{job_type}</span></td>
|
||||
<td class="payload-cell" title="{payload}">{payload}</td>
|
||||
<td>{last_run}</td>
|
||||
<td><span class="badge {status_cls}">{status_txt}</span></td>
|
||||
<td>
|
||||
<div class="cron-actions">
|
||||
<a href="/settings/crons/edit?cron_id={cid}&persona={pers_esc}"
|
||||
class="btn-cron">Edit</a>
|
||||
<form method="POST" action="/settings/crons/toggle" style="display:inline">
|
||||
<input type="hidden" name="cron_id" value="{cid}">
|
||||
<input type="hidden" name="persona" value="{pers_esc}">
|
||||
<button type="submit" class="btn-cron">{toggle_txt}</button>
|
||||
</form>
|
||||
<form method="POST" action="/settings/crons/remove" style="display:inline"
|
||||
onsubmit="return confirm('Delete this schedule?')">
|
||||
<input type="hidden" name="cron_id" value="{cid}">
|
||||
<input type="hidden" name="persona" value="{pers_esc}">
|
||||
<button type="submit" class="btn-cron btn-cron-del">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>""")
|
||||
|
||||
html_parts.append(f"""
|
||||
<div class="persona-group">
|
||||
<p class="persona-group-label">{_html.escape(persona)}</p>
|
||||
<table class="cron-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th><th>Schedule</th><th>Type</th>
|
||||
<th>Payload</th><th>Last run</th><th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{"".join(rows)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>""")
|
||||
|
||||
if all_empty:
|
||||
return '<div class="empty-state">No schedules yet. Add one below.</div>'
|
||||
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
def _persona_options(username: str, selected: str = "") -> str:
|
||||
personas = list_user_personas(username)
|
||||
return "\n".join(
|
||||
f'<option value="{_html.escape(p)}"{"selected" if p == selected else ""}>{_html.escape(p)}</option>'
|
||||
for p in personas
|
||||
)
|
||||
|
||||
|
||||
_TYPE_OPTIONS = ("remind", "note", "message", "brief", "task")
|
||||
_TYPE_LABELS = {
|
||||
"remind": "remind — append to REMINDERS.md",
|
||||
"note": "note — append to SCRATCH.md",
|
||||
"message": "message — send payload as-is",
|
||||
"brief": "brief — LLM response, no tools",
|
||||
"task": "task — full orchestrator tool loop",
|
||||
}
|
||||
|
||||
|
||||
def _render_edit_form(job: dict, persona: str) -> str:
|
||||
cid = _html.escape(job["id"])
|
||||
pers_esc = _html.escape(persona)
|
||||
label = _html.escape(job.get("label", ""))
|
||||
schedule = _html.escape(job.get("schedule", ""))
|
||||
payload = _html.escape(job.get("payload", ""))
|
||||
cur_type = job.get("type", "remind")
|
||||
|
||||
type_opts = "\n".join(
|
||||
f'<option value="{t}" {"selected" if t == cur_type else ""}>{_html.escape(_TYPE_LABELS.get(t, t))}</option>'
|
||||
for t in _TYPE_OPTIONS
|
||||
)
|
||||
|
||||
return f"""
|
||||
<div class="section" style="border: 2px solid var(--pg-accent); border-radius: 8px; padding: 1rem;">
|
||||
<h2 style="margin-top:0">Edit schedule</h2>
|
||||
<form method="POST" action="/settings/crons/save">
|
||||
<input type="hidden" name="cron_id" value="{cid}">
|
||||
<input type="hidden" name="persona" value="{pers_esc}">
|
||||
<div class="add-form-grid">
|
||||
<div class="field">
|
||||
<label>Persona</label>
|
||||
<input type="text" value="{pers_esc}" disabled style="opacity:0.5">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edit_job_type">Type</label>
|
||||
<select id="edit_job_type" name="job_type">{type_opts}</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edit_label">Label</label>
|
||||
<input type="text" id="edit_label" name="label" value="{label}" required autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="edit_schedule">Schedule</label>
|
||||
<input type="text" id="edit_schedule" name="schedule" value="{schedule}"
|
||||
required autocomplete="off" spellcheck="false">
|
||||
<p class="hint">
|
||||
hourly · daily · daily:HH:MM · weekly:DOW · weekly:DOW:HH:MM ·
|
||||
monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM
|
||||
</p>
|
||||
</div>
|
||||
<div class="field field-full">
|
||||
<label for="edit_payload">Payload / prompt</label>
|
||||
<textarea id="edit_payload" name="payload" rows="3" required>{payload}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem; align-items:center; margin-top:0.5rem">
|
||||
<button type="submit" class="btn-submit" style="margin-top:0">Save changes</button>
|
||||
<a href="/settings/crons" style="font-size:0.85rem; color:var(--pg-muted)">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _render_page(username: str, back_persona: str = "", success: str = "", error: str = "",
|
||||
edit_html: str = "") -> str:
|
||||
html = (_STATIC / "crons.html").read_text()
|
||||
html = html.replace("{{ edit_html }}", edit_html)
|
||||
html = html.replace("{{ cron_list_html }}", _render_cron_list(username))
|
||||
html = html.replace("{{ persona_options }}", _persona_options(username, back_persona))
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{_html.escape(success)}</p>')
|
||||
if error:
|
||||
html = html.replace("<!-- ERROR -->", f'<p class="error">{_html.escape(error)}</p>')
|
||||
return html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/settings/crons", include_in_schema=False)
|
||||
async def crons_page(request: Request):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
return HTMLResponse(_render_page(username, back_persona))
|
||||
|
||||
|
||||
@router.post("/settings/crons/add", include_in_schema=False)
|
||||
async def cron_add(
|
||||
request: Request,
|
||||
persona: str = Form(""),
|
||||
label: str = Form(""),
|
||||
schedule: str = Form(""),
|
||||
job_type: str = Form(""),
|
||||
payload: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
|
||||
label = label.strip()
|
||||
schedule = schedule.strip()
|
||||
payload = payload.strip()
|
||||
persona = persona.strip()
|
||||
|
||||
_VALID_TYPES = ("remind", "note", "message", "brief", "task")
|
||||
if job_type not in _VALID_TYPES:
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}"))
|
||||
|
||||
try:
|
||||
sched_kwargs = parse_schedule(schedule)
|
||||
except ValueError as e:
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}"))
|
||||
|
||||
if not label:
|
||||
return HTMLResponse(_render_page(username, back_persona, error="Label is required."))
|
||||
if not payload:
|
||||
return HTMLResponse(_render_page(username, back_persona, error="Payload is required."))
|
||||
|
||||
crons = load_crons(username, persona)
|
||||
job = {
|
||||
"id": _short_id(),
|
||||
"user": username,
|
||||
"persona": persona,
|
||||
"label": label,
|
||||
"schedule": schedule,
|
||||
"type": job_type,
|
||||
"payload": payload,
|
||||
"enabled": True,
|
||||
"created_at": _now(),
|
||||
"last_run": None,
|
||||
}
|
||||
crons.append(job)
|
||||
save_crons(crons, username, persona)
|
||||
_scheduler_add(job, sched_kwargs)
|
||||
|
||||
logger.info("cron added via UI: %s %s [%s]", job["id"], schedule, job_type)
|
||||
return HTMLResponse(_render_page(username, back_persona, success=f"Schedule '{label}' added."))
|
||||
|
||||
|
||||
@router.post("/settings/crons/toggle", include_in_schema=False)
|
||||
async def cron_toggle(
|
||||
request: Request,
|
||||
cron_id: str = Form(""),
|
||||
persona: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
|
||||
crons = load_crons(username, persona)
|
||||
for c in crons:
|
||||
if c["id"] == cron_id:
|
||||
c["enabled"] = not c.get("enabled", True)
|
||||
save_crons(crons, username, persona)
|
||||
sched_id = f"{username}:{persona}:{cron_id}"
|
||||
if c["enabled"]:
|
||||
_scheduler_resume(sched_id)
|
||||
action = "resumed"
|
||||
else:
|
||||
_scheduler_pause(sched_id)
|
||||
action = "paused"
|
||||
logger.info("cron %s %s via UI", cron_id, action)
|
||||
return HTMLResponse(_render_page(username, back_persona, success=f"Schedule {action}."))
|
||||
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|
||||
|
||||
|
||||
@router.post("/settings/crons/remove", include_in_schema=False)
|
||||
async def cron_remove(
|
||||
request: Request,
|
||||
cron_id: str = Form(""),
|
||||
persona: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
|
||||
crons = load_crons(username, persona)
|
||||
before = len(crons)
|
||||
crons = [c for c in crons if c["id"] != cron_id]
|
||||
if len(crons) == before:
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|
||||
|
||||
save_crons(crons, username, persona)
|
||||
_scheduler_remove(f"{username}:{persona}:{cron_id}")
|
||||
logger.info("cron %s removed via UI", cron_id)
|
||||
return HTMLResponse(_render_page(username, back_persona, success="Schedule deleted."))
|
||||
|
||||
|
||||
@router.get("/settings/crons/edit", include_in_schema=False)
|
||||
async def cron_edit_page(request: Request, cron_id: str = "", persona: str = ""):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
|
||||
crons = load_crons(username, persona)
|
||||
job = next((c for c in crons if c["id"] == cron_id), None)
|
||||
if not job:
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|
||||
|
||||
edit_html = _render_edit_form(job, persona)
|
||||
return HTMLResponse(_render_page(username, back_persona, edit_html=edit_html))
|
||||
|
||||
|
||||
@router.post("/settings/crons/save", include_in_schema=False)
|
||||
async def cron_save(
|
||||
request: Request,
|
||||
cron_id: str = Form(""),
|
||||
persona: str = Form(""),
|
||||
label: str = Form(""),
|
||||
schedule: str = Form(""),
|
||||
job_type: str = Form(""),
|
||||
payload: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
|
||||
label = label.strip()
|
||||
schedule = schedule.strip()
|
||||
payload = payload.strip()
|
||||
|
||||
if job_type not in _TYPE_OPTIONS:
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}"))
|
||||
if not label:
|
||||
return HTMLResponse(_render_page(username, back_persona, error="Label is required."))
|
||||
if not payload:
|
||||
return HTMLResponse(_render_page(username, back_persona, error="Payload is required."))
|
||||
|
||||
try:
|
||||
sched_kwargs = parse_schedule(schedule)
|
||||
except ValueError as e:
|
||||
# Re-render with the edit form still open so the user can fix the schedule
|
||||
crons = load_crons(username, persona)
|
||||
job = next((c for c in crons if c["id"] == cron_id), None)
|
||||
edit_html = _render_edit_form(job, persona) if job else ""
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}",
|
||||
edit_html=edit_html))
|
||||
|
||||
crons = load_crons(username, persona)
|
||||
for c in crons:
|
||||
if c["id"] == cron_id:
|
||||
c["label"] = label
|
||||
c["schedule"] = schedule
|
||||
c["type"] = job_type
|
||||
c["payload"] = payload
|
||||
save_crons(crons, username, persona)
|
||||
# Replace the live scheduler job with the updated schedule
|
||||
sched_id = f"{username}:{persona}:{cron_id}"
|
||||
_scheduler_remove(sched_id)
|
||||
if c.get("enabled", True):
|
||||
_scheduler_add(c, sched_kwargs)
|
||||
logger.info("cron %s updated via UI [%s]", cron_id, schedule)
|
||||
return HTMLResponse(_render_page(username, back_persona,
|
||||
success=f"Schedule '{label}' updated."))
|
||||
|
||||
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
|
||||
@@ -12,7 +12,7 @@ import jwt
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token
|
||||
from auth_utils import COOKIE_NAME, decode_token, _read_auth
|
||||
from persona import list_user_personas
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -64,4 +64,7 @@ async def help_page(request: Request, persona: str = ""):
|
||||
f'{{user: "{username}", persona: "{back_persona}", backHref: "{back_href}"}};</script>'
|
||||
)
|
||||
html = html.replace("</head>", f"{config_tag}\n</head>", 1)
|
||||
nav = '<a href="/settings/integrations" class="nav-link">Integrations</a>' \
|
||||
if _read_auth(username).get("role", "user") == "admin" else ""
|
||||
html = html.replace("{{ integrations_nav }}", nav)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
Model Registry settings — providers, hosts, models, and role assignments.
|
||||
|
||||
Routes:
|
||||
GET /settings/models → settings page (canonical)
|
||||
GET /settings/local → redirect to /settings/models
|
||||
POST /settings/local/host → save/create a local host
|
||||
POST /settings/local/host/{id}/remove → remove a host (and its models)
|
||||
POST /settings/local/google-account → save/create a Google account
|
||||
GET /settings/models → settings page (canonical)
|
||||
GET /settings/local → redirect to /settings/models
|
||||
POST /settings/local/host → save/create a local host
|
||||
POST /settings/local/host/{id}/remove → remove a host (and its models)
|
||||
POST /settings/local/google-account → save/create a Google account
|
||||
POST /settings/local/google-account/{id}/remove → remove a Google account
|
||||
POST /settings/local/models/add → add a model (any provider)
|
||||
POST /settings/local/models/{id}/edit → edit an existing model entry
|
||||
POST /settings/local/models/{id}/remove → remove a model
|
||||
POST /api/models/role → AJAX: set a role assignment
|
||||
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
|
||||
POST /settings/local/anthropic-key → save/create an Anthropic API key
|
||||
POST /settings/local/anthropic-key/{id}/remove → remove an Anthropic API key
|
||||
POST /settings/local/models/add → add a model (any provider)
|
||||
POST /settings/local/models/{id}/edit → edit an existing model entry
|
||||
POST /settings/local/models/{id}/remove → remove a model
|
||||
POST /settings/local/roles/add → add a custom role (redirects to #roles)
|
||||
POST /settings/local/roles/remove → remove a custom role (redirects to #roles)
|
||||
POST /api/models/role → AJAX: set a role assignment
|
||||
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
|
||||
"""
|
||||
import json as _json
|
||||
import logging
|
||||
@@ -23,17 +27,101 @@ import jwt
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token
|
||||
from auth_utils import COOKIE_NAME, decode_token, _read_auth
|
||||
from config import settings as app_settings
|
||||
from persona import list_user_personas
|
||||
import model_registry as reg
|
||||
from tools import TOOL_CATEGORIES
|
||||
|
||||
_LAST_PERSONA_COOKIE = "cx_last_persona"
|
||||
|
||||
|
||||
def _preferred_persona(request: Request, username: str) -> str:
|
||||
names = list_user_personas(username)
|
||||
if not names:
|
||||
return ""
|
||||
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
|
||||
if cookie_val in names:
|
||||
return cookie_val
|
||||
return names[0]
|
||||
|
||||
|
||||
def _integrations_nav(username: str) -> str:
|
||||
role = _read_auth(username).get("role", "user")
|
||||
if role == "admin":
|
||||
return '<a href="/settings/integrations" class="nav-link">Integrations</a>'
|
||||
return ""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_STATIC = Path(__file__).parent.parent / "static"
|
||||
|
||||
|
||||
def _host_row_html(h: dict) -> str:
|
||||
"""Return the HTML for one host config row (edit form + remove link)."""
|
||||
api_key = h.get("api_key", "")
|
||||
key_hint = f"…{api_key[-4:]}" if api_key else "not set"
|
||||
ht = h.get("host_type", "openwebui")
|
||||
ow = ' selected' if ht == "openwebui" else ''
|
||||
ai = ' selected' if ht == "openai" else ''
|
||||
hid = h["id"]
|
||||
hlbl = h.get("label", "")
|
||||
hurl = h.get("api_url", "")
|
||||
maxc = h.get("max_concurrent", 3)
|
||||
return f'''
|
||||
<div class="host-row">
|
||||
<form method="POST" action="/settings/local/host" class="host-form">
|
||||
<input type="hidden" name="host_id" value="{hid}">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Label</label>
|
||||
<input type="text" name="label" value="{hlbl}"
|
||||
placeholder="Gaming Laptop" autocomplete="off" data-form-type="other">
|
||||
</div>
|
||||
<div class="field" style="flex:2">
|
||||
<label>API URL</label>
|
||||
<input type="text" name="api_url" value="{hurl}"
|
||||
placeholder="http://192.168.x.x:3000"
|
||||
autocomplete="off" spellcheck="false" data-form-type="other">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
|
||||
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
||||
data-form-type="other">
|
||||
<p class="key-status">Current: {key_hint}</p>
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 auto">
|
||||
<label>Type</label>
|
||||
<select name="host_type">
|
||||
<option value="openwebui"{ow}>Open WebUI / Ollama</option>
|
||||
<option value="openai"{ai}>OpenAI-compatible API</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 auto; width:6rem">
|
||||
<label>Max parallel</label>
|
||||
<input type="number" name="max_concurrent" min="1" max="20"
|
||||
value="{maxc}" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
|
||||
data-host-id="{hid}">Fetch models</button>
|
||||
<span class="fetch-status" id="fetch-{hid}"></span>
|
||||
</div>
|
||||
</form>
|
||||
<form method="POST" action="/settings/local/host/{hid}/remove"
|
||||
onsubmit="return confirm('Remove host and all its models?')"
|
||||
style="margin-top:0.5rem">
|
||||
<button type="submit" class="btn-link danger">Remove host</button>
|
||||
</form>
|
||||
</div>'''
|
||||
|
||||
|
||||
# ── Auth helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_user(request: Request) -> str | None:
|
||||
@@ -48,7 +136,7 @@ def _get_user(request: Request) -> str | None:
|
||||
|
||||
# ── Page renderer ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
def _render(username: str, request: Request | None = None, success: str = "", error: str = "") -> str:
|
||||
registry = reg.get_registry(username)
|
||||
hosts = registry.get("hosts", [])
|
||||
models = registry.get("models", [])
|
||||
@@ -75,77 +163,48 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
if not google_account_rows:
|
||||
google_account_rows = '<p class="empty-note">No accounts configured yet.</p>'
|
||||
|
||||
# ── Local host rows ───────────────────────────────────────────────────────
|
||||
host_rows = ""
|
||||
for h in hosts:
|
||||
key_hint = f"…{h['api_key'][-4:]}" if h.get("api_key") else "not set"
|
||||
ht = h.get("host_type", "openwebui")
|
||||
ow = ' selected' if ht == "openwebui" else ''
|
||||
ai = ' selected' if ht == "openai" else ''
|
||||
host_rows += f'''
|
||||
<div class="host-row">
|
||||
<form method="POST" action="/settings/local/host" class="host-form">
|
||||
<input type="hidden" name="host_id" value="{h["id"]}">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Label</label>
|
||||
<input type="text" name="label" value="{h.get("label","")}"
|
||||
placeholder="Gaming Laptop" autocomplete="off" data-form-type="other">
|
||||
</div>
|
||||
<div class="field" style="flex:2">
|
||||
<label>API URL</label>
|
||||
<input type="text" name="api_url" value="{h.get("api_url","")}"
|
||||
placeholder="http://192.168.x.x:3000"
|
||||
autocomplete="off" spellcheck="false" data-form-type="other">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
|
||||
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
||||
data-form-type="other">
|
||||
<p class="key-status">Current: {key_hint}</p>
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 auto">
|
||||
<label>Type</label>
|
||||
<select name="host_type">
|
||||
<option value="openwebui"{ow}>Open WebUI / Ollama</option>
|
||||
<option value="openai"{ai}>OpenAI-compatible (OpenRouter, etc.)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 auto; width:6rem">
|
||||
<label>Max parallel</label>
|
||||
<input type="number" name="max_concurrent" min="1" max="20"
|
||||
value="{h.get('max_concurrent', 3)}" style="width:100%">
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
|
||||
data-host-id="{h["id"]}">Fetch models</button>
|
||||
<span class="fetch-status" id="fetch-{h["id"]}"></span>
|
||||
</div>
|
||||
</form>
|
||||
<form method="POST" action="/settings/local/host/{h["id"]}/remove"
|
||||
onsubmit="return confirm('Remove host and all its models?')"
|
||||
style="margin-top:0.5rem">
|
||||
<button type="submit" class="btn-link danger">Remove host</button>
|
||||
</form>
|
||||
</div>'''
|
||||
if not host_rows:
|
||||
host_rows = '<p class="empty-note">No hosts configured yet. Add one below.</p>'
|
||||
# ── Host rows — split cloud (openai) vs local (openwebui) ─────────────────
|
||||
cloud_hosts = [h for h in hosts if h.get("host_type") == "openai"]
|
||||
local_hosts = [h for h in hosts if h.get("host_type", "openwebui") != "openai"]
|
||||
|
||||
cloud_host_rows = "".join(_host_row_html(h) for h in cloud_hosts)
|
||||
local_host_rows = "".join(_host_row_html(h) for h in local_hosts)
|
||||
if not cloud_host_rows:
|
||||
cloud_host_rows = '<p class="empty-note">No cloud API services configured yet. Add one below.</p>'
|
||||
if not local_host_rows:
|
||||
local_host_rows = '<p class="empty-note">No local hosts configured yet. Add one below.</p>'
|
||||
|
||||
host_options = "".join(
|
||||
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
|
||||
for h in hosts
|
||||
)
|
||||
|
||||
# ── Anthropic API key rows ────────────────────────────────────────────────
|
||||
anthropic_api_keys = reg.get_anthropic_api_keys(username)
|
||||
anthropic_keys_js = _json.dumps(anthropic_api_keys)
|
||||
anthropic_key_rows = ""
|
||||
for c in anthropic_api_keys:
|
||||
hint = c.get("hint", "no key")
|
||||
anthropic_key_rows += f'''
|
||||
<div class="account-row">
|
||||
<div>
|
||||
<span class="account-label">{c.get("label") or "API Key"}</span>
|
||||
<span class="account-hint">{hint}</span>
|
||||
</div>
|
||||
<form method="POST" action="/settings/local/anthropic-key/{c["id"]}/remove"
|
||||
onsubmit="return confirm('Remove this Anthropic API key?')">
|
||||
<button type="submit" class="btn-link danger">Remove</button>
|
||||
</form>
|
||||
</div>'''
|
||||
if not anthropic_key_rows:
|
||||
anthropic_key_rows = '<p class="empty-note">No API keys configured. Add one below or use Claude CLI (OAuth).</p>'
|
||||
|
||||
# ── Model rows (all providers) ────────────────────────────────────────────
|
||||
_PROVIDER_BADGE = {
|
||||
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
|
||||
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
|
||||
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
|
||||
"claude_cli": ('<span class="pbadge pb-anthropic">Anthropic</span>', "Claude CLI"),
|
||||
"anthropic_api": ('<span class="pbadge pb-anthropic">Anthropic</span>', "API Key"),
|
||||
"gemini_api": ('<span class="pbadge pb-google">Google</span>', ""),
|
||||
"local_openai": ('<span class="pbadge pb-local">Local</span>', ""),
|
||||
}
|
||||
model_rows = ""
|
||||
for m in models:
|
||||
@@ -201,6 +260,17 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
f'<div class="field"><label>Google Account</label>'
|
||||
f'<select name="account_id">{acct_opts}</select></div>'
|
||||
)
|
||||
elif mtype == "anthropic_api":
|
||||
key_opts = "".join(
|
||||
f'<option value="{c["id"]}"'
|
||||
f'{" selected" if c["id"] == m.get("credential_id") else ""}>'
|
||||
f'{c.get("label","API Key")} ({c.get("hint","")})</option>'
|
||||
for c in anthropic_api_keys
|
||||
)
|
||||
extra_fields = (
|
||||
f'<div class="field"><label>API Key</label>'
|
||||
f'<select name="credential_id">{key_opts or "<option value=\"\">No API keys configured</option>"}</select></div>'
|
||||
)
|
||||
else:
|
||||
extra_fields = '<input type="hidden" name="credential_id" value="cli">'
|
||||
|
||||
@@ -306,15 +376,35 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
model_opts += f' <option value="{m["id"]}">{lbl}</option>\n'
|
||||
model_opts += '</optgroup>\n'
|
||||
|
||||
all_roles = reg.get_all_roles(username)
|
||||
|
||||
role_rows = ""
|
||||
for role in app_settings.get_defined_roles():
|
||||
for role in all_roles:
|
||||
is_required = role in reg.REQUIRED_ROLES
|
||||
role_cfg = roles.get(role, {})
|
||||
role_title = role.replace("_", " ").title()
|
||||
required_badge = (
|
||||
'<span class="required-badge">required</span>'
|
||||
if is_required else ''
|
||||
)
|
||||
rcp_danger = (
|
||||
'' if is_required else
|
||||
f'<div class="rcp-danger">'
|
||||
f'<form method="POST" action="/settings/local/roles/remove" class="remove-role-form">'
|
||||
f'<input type="hidden" name="role_name" value="{role}">'
|
||||
f'<button type="submit" class="btn-link danger" data-role="{role}">Remove this role…</button>'
|
||||
f'</form>'
|
||||
f'</div>'
|
||||
)
|
||||
role_rows += (
|
||||
f'<div class="role-row" data-role="{role}">'
|
||||
f'<span class="role-name">{role.title()}</span>'
|
||||
f'<div class="role-name-col">'
|
||||
f'<span class="role-name">{role_title}</span>'
|
||||
f'{required_badge}'
|
||||
f'</div>'
|
||||
f'<div class="role-slots">'
|
||||
)
|
||||
for slot in reg.PRIORITY_KEYS[:3]:
|
||||
for slot in reg.PRIORITY_KEYS[:2]:
|
||||
slot_label = slot.replace("_", " ").title()
|
||||
sel = (
|
||||
f'<select class="role-select" data-role="{role}" '
|
||||
@@ -323,7 +413,7 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel}</div>'
|
||||
role_rows += (
|
||||
f'</div>'
|
||||
f'<button class="role-cfg-btn" data-role="{role}" title="Configure persona and tools">⚙</button>'
|
||||
f'<button class="role-cfg-btn" data-role="{role}" title="Configure">⚙</button>'
|
||||
f'</div>'
|
||||
f'<div class="role-config-panel" id="rcp-{role}">'
|
||||
f'<div class="rcp-field">'
|
||||
@@ -331,17 +421,18 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
f'<textarea class="rcp-textarea" data-role="{role}" rows="3" '
|
||||
f'placeholder="Extra instructions injected into the system prompt when this role is active…"></textarea>'
|
||||
f'</div>'
|
||||
f'<div class="rcp-field rcp-field-inline">'
|
||||
f'<div class="rcp-field">'
|
||||
f'<div style="display:flex;flex-direction:column;gap:0.3rem">'
|
||||
f'<label class="rcp-check">'
|
||||
f'<input type="checkbox" class="rcp-datetime-cb" data-role="{role}" checked>'
|
||||
f' Inject current date & time into system prompt'
|
||||
f'<span>Inject current date & time into system prompt</span>'
|
||||
f'</label>'
|
||||
f'<label class="rcp-check" style="margin-top:0.4rem">'
|
||||
f'<label class="rcp-check">'
|
||||
f'<input type="checkbox" class="rcp-mode-cb" data-role="{role}" checked>'
|
||||
f' Inject session mode (Chat / Off The Record) into system prompt'
|
||||
f'<span>Inject session mode (Chat / Off The Record) into system prompt</span>'
|
||||
f'</label>'
|
||||
f'<span class="rcp-hint" style="display:block;margin-top:0.2rem">'
|
||||
f'Disable both for pure processing roles (summarizer, classifier, translator)</span>'
|
||||
f'</div>'
|
||||
f'<p class="rcp-hint" style="margin-top:0.4rem">Disable both for pure processing roles (summarizer, classifier, translator).</p>'
|
||||
f'</div>'
|
||||
f'<div class="rcp-field">'
|
||||
f'<label class="rcp-label">Tool allow-list '
|
||||
@@ -352,12 +443,13 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
f'<button class="btn btn-primary btn-sm rcp-save" data-role="{role}">Save</button>'
|
||||
f'<button class="btn btn-secondary btn-sm rcp-cancel" data-role="{role}">Cancel</button>'
|
||||
f'</div>'
|
||||
f'{rcp_danger}'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
role_data_js = _json.dumps({
|
||||
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
|
||||
for role in app_settings.get_defined_roles()
|
||||
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:2]}
|
||||
for role in all_roles
|
||||
})
|
||||
|
||||
role_config_data_js = _json.dumps({
|
||||
@@ -367,34 +459,45 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
"inject_datetime": roles.get(role, {}).get("inject_datetime", True),
|
||||
"inject_mode": roles.get(role, {}).get("inject_mode", True),
|
||||
}
|
||||
for role in app_settings.get_defined_roles()
|
||||
for role in all_roles
|
||||
})
|
||||
tool_categories_js = _json.dumps(TOOL_CATEGORIES)
|
||||
|
||||
# ── Catalog data + Google accounts for JS ─────────────────────────────────
|
||||
google_accounts_js = _json.dumps(reg.get_google_accounts(username))
|
||||
google_catalog_js = _json.dumps(reg.get_catalog("google"))
|
||||
google_accounts_js = _json.dumps(reg.get_google_accounts(username))
|
||||
google_catalog_js = _json.dumps(reg.get_catalog("google"))
|
||||
anthropic_catalog_js = _json.dumps(reg.get_catalog("anthropic"))
|
||||
cloud_catalog_js = _json.dumps(reg.get_catalog("cloud"))
|
||||
has_hosts = "true" if hosts else "false"
|
||||
|
||||
html = (_STATIC / "local_llm.html").read_text()
|
||||
replacements = {
|
||||
"{{ username }}": username,
|
||||
"{{ google_account_rows }}": google_account_rows,
|
||||
"{{ host_rows }}": host_rows,
|
||||
"{{ model_rows }}": model_rows,
|
||||
"{{ host_options }}": host_options,
|
||||
"{{ role_rows }}": role_rows,
|
||||
"{{ role_data_js }}": role_data_js,
|
||||
"{{ role_config_data_js }}": role_config_data_js,
|
||||
"{{ tool_categories_js }}": tool_categories_js,
|
||||
"{{ google_accounts_js }}": google_accounts_js,
|
||||
"{{ google_catalog_js }}": google_catalog_js,
|
||||
"{{ username }}": username,
|
||||
"{{ google_account_rows }}": google_account_rows,
|
||||
"{{ anthropic_key_rows }}": anthropic_key_rows,
|
||||
"{{ cloud_host_rows }}": cloud_host_rows,
|
||||
"{{ local_host_rows }}": local_host_rows,
|
||||
"{{ model_rows }}": model_rows,
|
||||
"{{ host_options }}": host_options,
|
||||
"{{ role_rows }}": role_rows,
|
||||
"{{ role_data_js }}": role_data_js,
|
||||
"{{ role_config_data_js }}": role_config_data_js,
|
||||
"{{ tool_categories_js }}": tool_categories_js,
|
||||
"{{ google_accounts_js }}": google_accounts_js,
|
||||
"{{ anthropic_keys_js }}": anthropic_keys_js,
|
||||
"{{ google_catalog_js }}": google_catalog_js,
|
||||
"{{ anthropic_catalog_js }}": anthropic_catalog_js,
|
||||
"{{ has_hosts }}": has_hosts,
|
||||
"{{ cloud_catalog_js }}": cloud_catalog_js,
|
||||
"{{ has_hosts }}": has_hosts,
|
||||
}
|
||||
for key, val in replacements.items():
|
||||
html = html.replace(key, val)
|
||||
|
||||
back_persona = _preferred_persona(request, username) if request else ""
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
|
||||
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>')
|
||||
if error:
|
||||
@@ -409,7 +512,7 @@ async def models_page_canonical(request: Request):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
return HTMLResponse(_render(username))
|
||||
return HTMLResponse(_render(username, request))
|
||||
|
||||
|
||||
@router.get("/settings/local", include_in_schema=False)
|
||||
@@ -428,9 +531,9 @@ async def save_google_account(
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not api_key.strip() and not account_id.strip():
|
||||
return HTMLResponse(_render(username, error="API key is required."))
|
||||
return HTMLResponse(_render(username, request, error="API key is required."))
|
||||
reg.save_google_account(username, account_id or None, label, api_key)
|
||||
return HTMLResponse(_render(username, success="Google account saved."))
|
||||
return HTMLResponse(_render(username, request, success="Google account saved."))
|
||||
|
||||
|
||||
@router.post("/settings/local/google-account/{account_id}/remove", include_in_schema=False)
|
||||
@@ -439,7 +542,32 @@ async def remove_google_account(request: Request, account_id: str):
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
reg.remove_google_account(username, account_id)
|
||||
return HTMLResponse(_render(username, success="Google account removed."))
|
||||
return HTMLResponse(_render(username, request, success="Google account removed."))
|
||||
|
||||
|
||||
@router.post("/settings/local/anthropic-key", include_in_schema=False)
|
||||
async def save_anthropic_api_key(
|
||||
request: Request,
|
||||
key_id: str = Form(""),
|
||||
label: str = Form(""),
|
||||
api_key: str = Form(""),
|
||||
):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not api_key.strip() and not key_id.strip():
|
||||
return HTMLResponse(_render(username, request, error="API key is required."))
|
||||
reg.save_anthropic_api_key(username, key_id or None, label, api_key)
|
||||
return HTMLResponse(_render(username, request, success="Anthropic API key saved."))
|
||||
|
||||
|
||||
@router.post("/settings/local/anthropic-key/{key_id}/remove", include_in_schema=False)
|
||||
async def remove_anthropic_api_key(request: Request, key_id: str):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
reg.remove_anthropic_api_key(username, key_id)
|
||||
return HTMLResponse(_render(username, request, success="Anthropic API key removed."))
|
||||
|
||||
|
||||
@router.post("/settings/local/host", include_in_schema=False)
|
||||
@@ -456,9 +584,9 @@ async def save_host(
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not api_url.strip():
|
||||
return HTMLResponse(_render(username, error="API URL is required."))
|
||||
return HTMLResponse(_render(username, request, error="API URL is required."))
|
||||
reg.save_host(username, host_id or None, label, api_url, api_key, host_type, max_concurrent)
|
||||
return HTMLResponse(_render(username, success="Host saved."))
|
||||
return HTMLResponse(_render(username, request, success="Host saved."))
|
||||
|
||||
|
||||
@router.post("/settings/local/host/{host_id}/remove", include_in_schema=False)
|
||||
@@ -467,7 +595,7 @@ async def remove_host(request: Request, host_id: str):
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
reg.remove_host(username, host_id)
|
||||
return HTMLResponse(_render(username, success="Host removed."))
|
||||
return HTMLResponse(_render(username, request, success="Host removed."))
|
||||
|
||||
|
||||
@router.post("/settings/local/models/add", include_in_schema=False)
|
||||
@@ -499,9 +627,9 @@ async def add_model(
|
||||
|
||||
if provider == "local":
|
||||
if not model_name.strip():
|
||||
return HTMLResponse(_render(username, error="Model name is required."))
|
||||
return HTMLResponse(_render(username, request, error="Model name is required."))
|
||||
if not host_id.strip():
|
||||
return HTMLResponse(_render(username, error="Select a host."))
|
||||
return HTMLResponse(_render(username, request, error="Select a host."))
|
||||
reg.save_model(username, None, host_id, label, model_name, context_k, tag_list,
|
||||
max_rounds=max_rounds_, tools=tools_bool,
|
||||
reasoning_budget_tokens=reasoning_budget_)
|
||||
@@ -509,9 +637,9 @@ async def add_model(
|
||||
|
||||
elif provider in ("google", "anthropic"):
|
||||
if not cloud_model_name.strip():
|
||||
return HTMLResponse(_render(username, error="Select a model from the catalog."))
|
||||
return HTMLResponse(_render(username, request, error="Select a model from the catalog."))
|
||||
if provider == "google" and not account_id.strip():
|
||||
return HTMLResponse(_render(username, error="Select a Google account."))
|
||||
return HTMLResponse(_render(username, request, error="Select a Google account."))
|
||||
reg.save_cloud_model(
|
||||
username, None, provider, cloud_model_name, label,
|
||||
account_id=account_id or None,
|
||||
@@ -521,10 +649,10 @@ async def add_model(
|
||||
)
|
||||
display = label or cloud_model_name
|
||||
else:
|
||||
return HTMLResponse(_render(username, error=f"Unknown provider: {provider}"))
|
||||
return HTMLResponse(_render(username, request, error=f"Unknown provider: {provider}"))
|
||||
|
||||
logger.info("model added: %s / %s (%s)", username, display, provider)
|
||||
return HTMLResponse(_render(username, success=f'Model "{display}" added.'))
|
||||
return HTMLResponse(_render(username, request, success=f'Model "{display}" added.'))
|
||||
|
||||
|
||||
@router.post("/settings/local/models/{model_id}/edit", include_in_schema=False)
|
||||
@@ -547,14 +675,14 @@ async def edit_model(
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not model_name.strip():
|
||||
return HTMLResponse(_render(username, error="Model name is required."))
|
||||
return HTMLResponse(_render(username, request, error="Model name is required."))
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
max_rounds_ = max_rounds or None
|
||||
tools_bool = tools != 0
|
||||
reasoning_budget_ = reasoning_budget_tokens or None
|
||||
if mtype == "local_openai":
|
||||
if not host_id.strip():
|
||||
return HTMLResponse(_render(username, error="Select a host for this model."))
|
||||
return HTMLResponse(_render(username, request, error="Select a host for this model."))
|
||||
reg.save_model(username, model_id, host_id, label, model_name, context_k, tag_list,
|
||||
max_rounds=max_rounds_, tools=tools_bool,
|
||||
reasoning_budget_tokens=reasoning_budget_)
|
||||
@@ -562,15 +690,15 @@ async def edit_model(
|
||||
reg.save_cloud_model(username, model_id, "google", model_name, label,
|
||||
account_id=account_id or None, context_k=context_k, tags=tag_list,
|
||||
max_rounds=max_rounds_, tools=tools_bool)
|
||||
elif mtype == "claude_cli":
|
||||
elif mtype in ("claude_cli", "anthropic_api"):
|
||||
reg.save_cloud_model(username, model_id, "anthropic", model_name, label,
|
||||
credential_id=credential_id or "cli", context_k=context_k, tags=tag_list,
|
||||
max_rounds=max_rounds_, tools=tools_bool)
|
||||
else:
|
||||
return HTMLResponse(_render(username, error=f"Unknown model type: {mtype}"))
|
||||
return HTMLResponse(_render(username, request, error=f"Unknown model type: {mtype}"))
|
||||
display = label.strip() or model_name.strip()
|
||||
logger.info("model edited: %s / %s (%s)", username, display, mtype)
|
||||
return HTMLResponse(_render(username, success=f'Model "{display}" updated.'))
|
||||
return HTMLResponse(_render(username, request, success=f'Model "{display}" updated.'))
|
||||
|
||||
|
||||
@router.post("/settings/local/models/{model_id}/remove", include_in_schema=False)
|
||||
@@ -579,7 +707,41 @@ async def remove_model(request: Request, model_id: str):
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
reg.remove_model(username, model_id)
|
||||
return HTMLResponse(_render(username, success="Model removed."))
|
||||
return HTMLResponse(_render(username, request, success="Model removed."))
|
||||
|
||||
|
||||
@router.post("/settings/local/roles/add", include_in_schema=False)
|
||||
async def add_custom_role_route(
|
||||
request: Request,
|
||||
role_name: str = Form(""),
|
||||
):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
name = role_name.strip().lower()
|
||||
if not name or not name[0].isalpha():
|
||||
return HTMLResponse(_render(username, request, error="Role name must start with a letter."))
|
||||
ok = reg.add_custom_role(username, name)
|
||||
if not ok:
|
||||
return HTMLResponse(_render(username, request, error=f'"{name}" is a required role and cannot be re-added.'))
|
||||
logger.info("custom role added: %s / %s", username, name)
|
||||
return RedirectResponse("/settings/models#roles", status_code=303)
|
||||
|
||||
|
||||
@router.post("/settings/local/roles/remove", include_in_schema=False)
|
||||
async def remove_custom_role_route(
|
||||
request: Request,
|
||||
role_name: str = Form(""),
|
||||
):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
name = role_name.strip()
|
||||
ok = reg.remove_custom_role(username, name)
|
||||
if not ok:
|
||||
return HTMLResponse(_render(username, request, error=f'"{name}" is a required role and cannot be removed.'))
|
||||
logger.info("custom role removed: %s / %s", username, name)
|
||||
return RedirectResponse("/settings/models#roles", status_code=303)
|
||||
|
||||
|
||||
@router.post("/api/models/role")
|
||||
|
||||
@@ -53,6 +53,14 @@ def _preferred_persona(request: Request, username: str) -> str:
|
||||
return names[0]
|
||||
|
||||
|
||||
def _integrations_nav(username: str) -> str:
|
||||
"""Return the Integrations nav link for admin users, empty string otherwise."""
|
||||
role = _read_auth(username).get("role", "user")
|
||||
if role == "admin":
|
||||
return '<a href="/settings/integrations" class="nav-link">Integrations</a>'
|
||||
return ""
|
||||
|
||||
|
||||
def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
|
||||
html = (_STATIC / "notifications.html").read_text()
|
||||
channels = get_user_channels(username)
|
||||
@@ -69,6 +77,7 @@ def _notifications_page(username: str, back_persona: str = "", success: str = ""
|
||||
ha = channels.get("homeassistant") or {}
|
||||
ha_url = _html.escape(ha.get("url", "") or "")
|
||||
ha_webhook_id = _html.escape(ha.get("webhook_id", "") or "")
|
||||
ha_tools_checked = "checked" if ha.get("tools", False) else ""
|
||||
|
||||
html = html.replace("{{ notify_channel }}", notify_ch)
|
||||
html = html.replace("{{ notify_email_override }}", notify_email)
|
||||
@@ -80,9 +89,11 @@ def _notifications_page(username: str, back_persona: str = "", success: str = ""
|
||||
html = html.replace("{{ gc_webhook }}", gc_webhook)
|
||||
html = html.replace("{{ ha_url }}", ha_url)
|
||||
html = html.replace("{{ ha_webhook_id }}", ha_webhook_id)
|
||||
html = html.replace("{{ ha_tools_checked }}", ha_tools_checked)
|
||||
html = html.replace("{{ ha_username }}", username)
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
if error:
|
||||
@@ -137,6 +148,25 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
|
||||
back_persona = personas[0] if personas else ""
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
if error:
|
||||
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
|
||||
return html
|
||||
|
||||
|
||||
def _integrations_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
|
||||
html = (_STATIC / "integrations.html").read_text()
|
||||
channels = get_user_channels(username)
|
||||
ae_db = channels.get("aether_db") or {}
|
||||
|
||||
html = html.replace("{{ ae_db_host }}", _html.escape(ae_db.get("host", "") or ""))
|
||||
html = html.replace("{{ ae_db_port }}", _html.escape(str(ae_db.get("port", 3306))))
|
||||
html = html.replace("{{ ae_db_name }}", _html.escape(ae_db.get("name", "") or ""))
|
||||
html = html.replace("{{ ae_db_user }}", _html.escape(ae_db.get("user", "") or ""))
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
if error:
|
||||
@@ -308,6 +338,7 @@ async def save_notifications(
|
||||
ha_url: str = Form(""),
|
||||
ha_token: str = Form(""),
|
||||
ha_webhook_id: str = Form(""),
|
||||
ha_tools: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
@@ -365,6 +396,7 @@ async def save_notifications(
|
||||
ha["token"] = ha_token.strip()
|
||||
if ha_webhook_id.strip():
|
||||
ha["webhook_id"] = ha_webhook_id.strip()
|
||||
ha["tools"] = ha_tools == "1"
|
||||
|
||||
channels_path.write_text(json.dumps(channels, indent=2) + "\n")
|
||||
logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none")
|
||||
@@ -405,3 +437,63 @@ async def save_http_allowlist(
|
||||
path.write_text(json.dumps(lines, indent=2))
|
||||
logger.info("http allowlist updated for %s (%d prefixes)", username, len(lines))
|
||||
return HTMLResponse(_settings_page(username, personas, back_persona, success=f"HTTP allowlist saved ({len(lines)} prefix{'es' if len(lines) != 1 else ''})."))
|
||||
|
||||
|
||||
def _require_admin(username: str) -> bool:
|
||||
return _read_auth(username).get("role", "user") == "admin"
|
||||
|
||||
|
||||
@router.get("/settings/integrations", include_in_schema=False)
|
||||
async def integrations_page(request: Request):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not _require_admin(username):
|
||||
return RedirectResponse("/settings", status_code=302)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
return HTMLResponse(_integrations_page(username, back_persona))
|
||||
|
||||
|
||||
@router.post("/settings/integrations", include_in_schema=False)
|
||||
async def save_integrations(
|
||||
request: Request,
|
||||
ae_db_host: str = Form(""),
|
||||
ae_db_port: str = Form("3306"),
|
||||
ae_db_name: str = Form(""),
|
||||
ae_db_user: str = Form(""),
|
||||
ae_db_password: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not _require_admin(username):
|
||||
return RedirectResponse("/settings", status_code=302)
|
||||
|
||||
back_persona = _preferred_persona(request, username)
|
||||
|
||||
channels_path = app_settings.home_root() / username / "channels.json"
|
||||
try:
|
||||
channels = json.loads(channels_path.read_text())
|
||||
except Exception:
|
||||
channels = {}
|
||||
|
||||
if "aether_db" not in channels:
|
||||
channels["aether_db"] = {}
|
||||
db = channels["aether_db"]
|
||||
|
||||
if ae_db_host.strip():
|
||||
db["host"] = ae_db_host.strip()
|
||||
try:
|
||||
db["port"] = int(ae_db_port.strip()) if ae_db_port.strip() else 3306
|
||||
except ValueError:
|
||||
db["port"] = 3306
|
||||
if ae_db_name.strip():
|
||||
db["name"] = ae_db_name.strip()
|
||||
if ae_db_user.strip():
|
||||
db["user"] = ae_db_user.strip()
|
||||
if ae_db_password.strip():
|
||||
db["password"] = ae_db_password.strip()
|
||||
|
||||
channels_path.write_text(json.dumps(channels, indent=2) + "\n")
|
||||
logger.info("integrations updated for %s", username)
|
||||
return HTMLResponse(_integrations_page(username, back_persona, success="Integration settings saved."))
|
||||
|
||||
@@ -15,7 +15,7 @@ import jwt
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token, get_tool_policy, save_tool_policy
|
||||
from auth_utils import COOKIE_NAME, decode_token, get_tool_policy, save_tool_policy, _read_auth
|
||||
from persona import list_user_personas
|
||||
from tools import TOOL_CATEGORIES, TOOL_RISK, CONFIRM_REQUIRED
|
||||
|
||||
@@ -123,6 +123,9 @@ def _tools_page(
|
||||
html = html.replace("{{ tool_deny }}", _html.escape("\n".join(policy.get("deny") or [])))
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
|
||||
nav = '<a href="/settings/integrations" class="nav-link">Integrations</a>' \
|
||||
if _read_auth(username).get("role", "user") == "admin" else ""
|
||||
html = html.replace("{{ integrations_nav }}", nav)
|
||||
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
and are appended automatically by help.html when present.
|
||||
-->
|
||||
|
||||
*Last updated: 2026-05-09*
|
||||
*Last updated: 2026-05-13*
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +43,7 @@ The **Context & Memory** panel (sliders icon with tier number) contains all conf
|
||||
| **Context Tier** | T1 – T4 context depth |
|
||||
| **Memory Layers** | Toggle Long / Mid / Short memory on/off |
|
||||
| **Distill Memory** | Manually trigger Short / Mid / Long / All distillation |
|
||||
| **Role** | Active LLM role — click to cycle through configured role assignments |
|
||||
| **Model** | Active chat model — click to cycle through your configured slot models (Primary → Backup 1 → …) |
|
||||
| **Display** | **Aa** cycles font size · **☾** toggles theme · **S/M/L** cycles input area height · **⌃↵** toggles send shortcut |
|
||||
|
||||
All settings persist in `localStorage` across page refreshes.
|
||||
@@ -55,11 +55,11 @@ All settings persist in `localStorage` across page refreshes.
|
||||
- **Send:** `Ctrl+Enter` by default. Click `⌃↵` in the input controls to toggle to plain `Enter` mode.
|
||||
- **Stop:** Click **Stop** to cancel an in-progress response at any time.
|
||||
- **Edit a message:** Hover over any message → click **edit**. `Ctrl+Enter` saves, `Esc` cancels.
|
||||
- **Delete a message:** Hover over any message → click **del**. Removes from session history.
|
||||
- **Copy a response:** Hover over any assistant message → click **copy**.
|
||||
- **Delete a message:** Hover over any message → click **del**, then **confirm delete**.
|
||||
- **Copy:** Hover over any message → click **copy**.
|
||||
- **New line while typing:** `Shift+Enter` (in `Ctrl+Enter` mode) or `Shift+Enter` / Enter (in Enter mode).
|
||||
|
||||
Each assistant response shows a small **model tag** in the bottom-right corner identifying which model and host responded.
|
||||
Each assistant response shows a small **model tag** below the message identifying which model and host responded.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,9 +70,9 @@ Click the **⚡** button in the input row to enable the Tools toggle. When lit (
|
||||
The orchestrator runs a multi-step tool loop:
|
||||
|
||||
1. The **orchestrator model** reasons about the request and calls tools as needed
|
||||
2. It produces an enriched summary of what it found
|
||||
3. The **responder model** (set by the active Role) receives that context and writes the final user-facing reply
|
||||
4. A `⚡ N tool calls: …` note appears below the response listing what was used
|
||||
2. Tool results are fed back into the conversation; the loop continues until the model has what it needs
|
||||
3. The model produces the final user-facing reply — when the orchestrator role uses Gemini, Claude writes the final response; when it uses a local model, that same model writes it
|
||||
4. Expandable tool-call cards appear above the response — click any card to see the arguments sent and the result returned
|
||||
|
||||
The ⚡ toggle is **independent of the Role selector** — you can use any role (chat, coder, research, etc.) with or without tools. The orchestrator model is configured in **Account → Model Registry → Role Assignments → Orchestrator**.
|
||||
|
||||
@@ -82,12 +82,14 @@ Orchestrated sessions persist to history exactly like regular chat.
|
||||
|
||||
### Available Tools
|
||||
|
||||
50 tools across 12 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call.
|
||||
69 tools across 17 categories. Tool schemas are narrowed per-message using keyword routing — only categories relevant to your request are sent, keeping token overhead low. Per-role tool sets provide additional filtering.
|
||||
|
||||
| Category | Tools |
|
||||
|---|---|
|
||||
| **Web** | `web_search`, `http_fetch`, `web_read`, `http_post` |
|
||||
| **Files** | `file_read`, `file_list`, `file_write`, `session_read`, `session_search` |
|
||||
| **Project Files** | `project_file_read`, `project_file_list`, `file_stat`, `file_grep`, `file_diff`, `file_syntax_check` |
|
||||
| **Files** (admin) | `file_read`, `file_list`, `file_write`, `session_read`, `session_search` |
|
||||
| **Git** | `git_status`, `git_log`, `git_diff` |
|
||||
| **Shell** | `shell_exec`, `claude_allow_dir` |
|
||||
| **System** | `cortex_restart`, `cortex_logs`, `cortex_status`, `cortex_update` |
|
||||
| **Tasks** | `task_list`, `task_create`, `task_update`, `task_complete` |
|
||||
@@ -96,12 +98,17 @@ Orchestrated sessions persist to history exactly like regular chat.
|
||||
| **Scratchpad** | `scratch_read`, `scratch_write`, `scratch_append`, `scratch_clear` |
|
||||
| **Notifications** | `web_push`, `email_send`, `nc_talk_send`, `nc_talk_history` |
|
||||
| **Aether Journals** | `ae_journal_list/search`, `ae_journal_entries_list`, `ae_journal_entry_read/create/update/disable/append/prepend` |
|
||||
| **Aether Tasks** | `ae_task_list` |
|
||||
| **Aether Database** (admin) | `ae_db_query`, `ae_db_describe`, `ae_db_show_view` |
|
||||
| **Agent Notes** | `agent_notes_read`, `agent_notes_write`, `agent_notes_append`, `agent_notes_clear` |
|
||||
| **Agents** | `spawn_agent` |
|
||||
| **Agents** | `spawn_agent`, `aider_run` |
|
||||
| **Home Assistant** | `ha_get_state`, `ha_get_states`, `ha_call_service` |
|
||||
|
||||
File, Shell, System, Agents, and some Notification/Web tools are **admin-only** and not visible to regular users.
|
||||
Files, Shell, System, Aether Database, Agents, and some Notification/Web tools are **admin-only** and not visible to regular users.
|
||||
`http_post` requires a URL prefix allowlist in `home/{user}/http_allowlist.json`.
|
||||
`nc_talk_history` requires `nc_username` and `nc_app_password` in `channels.json` under `nextcloud`.
|
||||
`ae_db_*` tools require Aether DB credentials configured in **Integrations** settings. All queries are SELECT-only — no writes possible.
|
||||
`aider_run` requires Aider installed (`pip install aider-chat`) and a model configured via `AIDER_MODEL` env var or the project's `.aider.conf.yml`. Supports any OpenAI-compatible backend — DeepSeek, OpenRouter, Ollama, etc.
|
||||
|
||||
### Per-Role Tool Sets
|
||||
|
||||
@@ -147,23 +154,16 @@ Once installed, opening Cortex from the home screen or app launcher skips the br
|
||||
|
||||
---
|
||||
|
||||
## Backends
|
||||
## Switching Models
|
||||
|
||||
Three backends are available:
|
||||
The **Model** button in the Context & Memory panel cycles through the slot models configured for your active role (Primary → Backup 1). Click it to switch between models mid-session.
|
||||
|
||||
| Backend | What it is |
|
||||
|---|---|
|
||||
| **Claude** | Anthropic Claude via the Claude CLI (OAuth — no API key needed) |
|
||||
| **Gemini** | Google Gemini via the Gemini CLI |
|
||||
| **Local** | Any OpenAI-compatible endpoint (Open WebUI, Ollama, OpenRouter, etc.) |
|
||||
- The button label shows the active model (e.g. "GPT-4o", "Gemini 2.5 Flash")
|
||||
- The selected slot is sent with each chat request so the correct model is used
|
||||
- If only one model is configured, the toggle does nothing
|
||||
- A system message appears in the chat when you switch models
|
||||
|
||||
The **Role** toggle in the Context & Memory panel cycles through configured role assignments. Each role maps to a Primary / Backup 1 / Backup 2 model chain set in the Model Registry.
|
||||
|
||||
- The active model label appears below the toggle button
|
||||
- `auto` (default) uses the model assigned to the `chat` role in your Model Registry
|
||||
- Forcing a specific backend overrides the role assignment for that session
|
||||
|
||||
If the active backend fails, a fallback is tried automatically. A **⚡** badge appears on the response when this happens.
|
||||
If the active model fails, the next configured backup slot is tried automatically.
|
||||
|
||||
Each response shows a **model tag** (bottom-right of message) with the model label and host, so you always know what responded.
|
||||
|
||||
@@ -178,7 +178,8 @@ Each response shows a **model tag** (bottom-right of message) with the model lab
|
||||
| **Account** | View your username, role badge (Admin / User), rename your username |
|
||||
| **Connected Accounts** | See which Google account is linked for OAuth sign-in |
|
||||
| **Email Allowlist** | Regex patterns controlling which addresses the `email_send` tool can reach |
|
||||
| **Notifications** | Dedicated page — set channel (Browser Push, NC Talk, Google Chat, email) for proactive messages; test buttons for instant verification |
|
||||
| **Notifications** | Dedicated page — set channel (Browser Push, NC Talk, Google Chat, email) for proactive messages; configure Home Assistant inbound webhook; test buttons for instant verification |
|
||||
| **Schedules** | View, add, edit, pause, and delete scheduled jobs directly — without going through the AI |
|
||||
| **Tool Permissions** | Allow or block specific orchestrator tools for your account |
|
||||
| **Usage** | Token consumption by model — see below |
|
||||
| **Browser Cache** | Clear UI preferences stored locally (theme, font size, session ID, etc.) |
|
||||
@@ -229,7 +230,9 @@ Configure which AI models are available and which handles each task type.
|
||||
|
||||
Do this before adding models — models need a provider account or local host to attach to.
|
||||
|
||||
**Anthropic (Claude):** Nothing to configure. Claude uses your existing CLI OAuth session. If Claude isn't working, run `claude auth login` in a terminal.
|
||||
**Anthropic (Claude):** Two options:
|
||||
- **CLI (OAuth):** Nothing to configure — uses your existing `claude auth login` session. If Claude isn't working, run `claude auth login` in a terminal.
|
||||
- **Direct API key:** Scroll to **Cloud Providers → Anthropic** → click **+ Add API key**. Enter a label and your `sk-ant-…` key from [console.anthropic.com/keys](https://console.anthropic.com/keys). When you add a model using an API key credential, it routes through the Anthropic SDK instead of the CLI.
|
||||
|
||||
**Google (Gemini):** Add one entry per API key you want to use:
|
||||
1. Scroll to **Cloud Providers → Google** → click **+ Add Google account**
|
||||
@@ -258,7 +261,7 @@ Scroll to **Add Model**. Select the provider tab, fill in the details, click **A
|
||||
|---|---|
|
||||
| **Local** | Select a host (from Step 1) → enter model name, or use **Fetch from host** to pick from a live list |
|
||||
| **Google** | Select a Gemini model from the catalog → select a Google account (from Step 1) |
|
||||
| **Anthropic** | Select a Claude model from the catalog → uses your CLI session automatically |
|
||||
| **Anthropic** | Select a credential (CLI OAuth or an API key added in Step 1) → select a Claude model from the catalog |
|
||||
|
||||
The label and context window size auto-fill from the catalog — edit them if you want. Tags are optional.
|
||||
|
||||
@@ -266,17 +269,24 @@ The label and context window size auto-fill from the catalog — edit them if yo
|
||||
|
||||
### Step 3 — Assign models to roles
|
||||
|
||||
Scroll to **Role Assignments** at the bottom of the page. Each role has **Primary**, **Backup 1**, and **Backup 2** slots — Primary is tried first, then backups in order. Changes save automatically.
|
||||
Scroll to **Role Assignments** at the bottom of the page. Each role has **Primary** and **Backup 1** slots — Primary is tried first, then Backup 1. Changes save automatically.
|
||||
|
||||
**Required roles** (always present, cannot be removed):
|
||||
|
||||
| Role | Used for |
|
||||
|---|---|
|
||||
| **Chat** | Regular conversation |
|
||||
| **Orchestrator** | Agent mode tool loop |
|
||||
| **Distill** | Memory distillation (short / mid / long) |
|
||||
| **Coder** | Code-focused tasks |
|
||||
| **Research** | Long-context research tasks |
|
||||
|
||||
Leave all slots empty to use the server default.
|
||||
**Custom roles** — Click **+ Add custom role** to create your own. Each custom role gets its own model selection, tool set, and system prompt addition. Good examples:
|
||||
|
||||
| Example | Purpose |
|
||||
|---|---|
|
||||
| **Coder** | Code-focused tasks — larger context window, code-aware model |
|
||||
| **Research** | Long-context research — high-token model, web tools prioritized |
|
||||
|
||||
Switch roles via the **Role** selector in the Context & Memory panel (⚙). Leave all slots empty to use the server default.
|
||||
|
||||
**Per-role tool sets:** Expand any role card to configure which tool categories the orchestrator can use when that role is active. Unchecked categories are hidden from the model entirely — reducing token overhead on every orchestrated call. Leaving all categories unchecked means all tools the user has access to are available (the default).
|
||||
|
||||
@@ -286,7 +296,7 @@ Leave all slots empty to use the server default.
|
||||
|
||||
## Nextcloud Talk Bot
|
||||
|
||||
Inara is registered as a bot in Nextcloud Talk.
|
||||
The Cortex bot is registered in Nextcloud Talk.
|
||||
|
||||
- Messages sent in enabled Talk conversations are received by Cortex, processed, and replied to.
|
||||
- The webhook returns `200 OK` immediately; the reply happens asynchronously.
|
||||
@@ -297,12 +307,12 @@ Inara is registered as a bot in Nextcloud Talk.
|
||||
|
||||
## Google Chat Bot
|
||||
|
||||
Inara is available as a bot in Google Chat (One Sky IT Workspace).
|
||||
The Cortex bot is available in Google Chat (One Sky IT Workspace).
|
||||
|
||||
- Send Inara a direct message in Google Chat to start a conversation.
|
||||
- Send the bot a direct message in Google Chat to start a conversation.
|
||||
- Each DM thread is its own session (`gc_spaces/*` prefix) — history persists across messages.
|
||||
- Responses are synchronous — Google Chat displays the reply directly in the thread.
|
||||
- To add Inara to a space: open the space, add a person/app, search for **Inara**.
|
||||
- To add the bot to a space: open the space, click **Add people & apps**, and search for the Cortex bot.
|
||||
- Sessions from Google Chat appear as `gc_*` prefixed IDs in the Sessions panel.
|
||||
|
||||
---
|
||||
@@ -337,9 +347,9 @@ Cortex can send browser push notifications — even when the tab is closed.
|
||||
- Open **☰ → Enable notifications** and accept the browser permission prompt.
|
||||
- Once enabled, the button shows **Notifications on** (in accent colour).
|
||||
- Click again to disable. Subscriptions are stored per-device.
|
||||
- The orchestrator's `web_push` tool lets Inara send you a push proactively (e.g. when a long task completes).
|
||||
- The orchestrator's `web_push` tool lets your persona send you a push proactively (e.g. when a long task completes).
|
||||
|
||||
**Notification channel settings:** ☰ → **Account** → **Notification settings →** — choose Browser Push, Email, Nextcloud Talk, or Google Chat as the channel Inara uses for scheduled reminders, cron job completions, and memory digests. Use the **Send Test Notification** button to verify your setup, or **Check Reminders Now** to trigger the reminder check immediately.
|
||||
**Notification channel settings:** ☰ → **Account** → **Notification settings →** — choose Browser Push, Email, Nextcloud Talk, or Google Chat as the channel your persona uses for scheduled reminders, cron job completions, and memory digests. Use the **Send Test Notification** button to verify your setup, or **Check Reminders Now** to trigger the reminder check immediately.
|
||||
|
||||
---
|
||||
|
||||
@@ -385,6 +395,53 @@ Distillation builds up the memory layers from raw session logs. Runs automatical
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Jobs
|
||||
|
||||
Cortex can run recurring jobs on a schedule — reminders, daily briefings, automated research, and more. Manage them by asking your persona to set them up, or go directly to **☰ → Account → Schedules**.
|
||||
|
||||
### Job Types
|
||||
|
||||
| Type | What it does |
|
||||
|---|---|
|
||||
| `remind` | Appends to `REMINDERS.md` — automatically surfaced in chat context |
|
||||
| `note` | Appends to `SCRATCH.md` — read on demand via the scratchpad |
|
||||
| `message` | Sends the payload text directly to your notification channel |
|
||||
| `brief` | Calls the AI with your payload as the prompt, sends the response to your notification channel. Good for morning briefings, check-ins. |
|
||||
| `task` | Runs the full orchestrator tool loop with your payload as the request, sends Claude's response to your notification channel. Use this for agentic scheduled work: research, file updates, summaries that need tool access. |
|
||||
|
||||
For `task` jobs: tools that require confirmation are skipped in scheduled context. Pre-approve them in **Settings → Tools** to allow them in scheduled tasks.
|
||||
|
||||
### Schedule Formats
|
||||
|
||||
| Format | When it runs |
|
||||
|---|---|
|
||||
| `hourly` | Every hour at :00 |
|
||||
| `daily` | Every day at 09:00 |
|
||||
| `daily:HH:MM` | Every day at the specified time |
|
||||
| `weekly:DOW` | Every specified day at 09:00 (e.g. `weekly:mon`) |
|
||||
| `weekly:DOW:HH:MM` | Every specified day at the specified time (e.g. `weekly:fri:17:00`) |
|
||||
| `monthly` | 1st of every month at 09:00 |
|
||||
| `monthly:DD` | Specific day of month at 09:00 (e.g. `monthly:15`) |
|
||||
| `monthly:DD:HH:MM` | Specific day of month at the specified time |
|
||||
| `yearly:MM:DD` | Every year on that date at 09:00 — for birthdays, anniversaries (e.g. `yearly:03:15`) |
|
||||
| `yearly:MM:DD:HH:MM` | Every year on that date at the specified time |
|
||||
|
||||
DOW values: `mon tue wed thu fri sat sun`. All times are server-local.
|
||||
|
||||
Schedules take effect immediately when added or edited — no restart needed. Paused jobs stay in the list and can be resumed at any time.
|
||||
|
||||
### Home Assistant Integration
|
||||
|
||||
HA automations can trigger your persona via webhook. Configure in **Notifications → Home Assistant → Inbound webhook**:
|
||||
|
||||
- Set a **Webhook ID** (long random string — this is your secret URL component)
|
||||
- Your endpoint: `https://cortex.dgrzone.com/webhook/ha/{username}/{webhook_id}`
|
||||
- **Enable orchestrator tools** — when checked, HA events trigger the full tool loop; when unchecked, events get a direct LLM response (faster, no tools)
|
||||
|
||||
HA payload fields recognized: `message`, `entity_id`, `state`, `trigger`, `event`, `area`.
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Keys | Action |
|
||||
@@ -447,10 +504,12 @@ Chat request body (`POST /chat`):
|
||||
"message": "string",
|
||||
"session_id": "string | null",
|
||||
"tier": 2,
|
||||
"model": "claude | gemini | local | null",
|
||||
"chat_role": "chat",
|
||||
"slot": "primary | backup_1 | backup_2 | null",
|
||||
"include_long": true,
|
||||
"include_mid": true,
|
||||
"include_short": true
|
||||
"include_short": true,
|
||||
"off_record": false
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -313,8 +313,8 @@
|
||||
});
|
||||
|
||||
// ── Tools toggle ─────────────────────────────────────────────
|
||||
// When on: submit goes to POST /orchestrate (Gemini tool loop → active role responds).
|
||||
// When off: submit goes to POST /chat (direct to active role, no tools).
|
||||
// When on: submit goes to POST /orchestrate (orchestrator tool loop → active model responds).
|
||||
// When off: submit goes to POST /chat (direct to active model, no tools).
|
||||
let toolsEnabled = localStorage.getItem('tools-enabled') === 'true';
|
||||
let _runStart = 0;
|
||||
let _runTimer = null;
|
||||
@@ -335,9 +335,8 @@
|
||||
});
|
||||
|
||||
function updateSendBtnTitle() {
|
||||
const role = activeRole();
|
||||
const rmodel = role?.model_label || '(server default)';
|
||||
const rname = role?.label || 'Chat';
|
||||
const entry = activeChatModel();
|
||||
const rmodel = entry?.label || '(server default)';
|
||||
const mode = current_mode === 'otr' ? 'Off The Record'
|
||||
: current_mode === 'note' ? 'Note'
|
||||
: 'Chat';
|
||||
@@ -347,13 +346,13 @@
|
||||
if (useOrch) {
|
||||
const omodel = orchestratorModel || '(server default)';
|
||||
lines = [
|
||||
`Role: ${rname} · ${rmodel}`,
|
||||
`Model: ${rmodel}`,
|
||||
`Orchestrator: ${omodel} (tool loop)`,
|
||||
`Mode: ${mode}`,
|
||||
];
|
||||
} else {
|
||||
lines = [
|
||||
`Role: ${rname} · ${rmodel}`,
|
||||
`Model: ${rmodel}`,
|
||||
`Mode: ${mode}`,
|
||||
`Engine: Direct (no tool loop)`,
|
||||
];
|
||||
@@ -364,14 +363,13 @@
|
||||
function startRunTimer() {
|
||||
_runStart = Date.now();
|
||||
function tick() {
|
||||
const secs = Math.floor((Date.now() - _runStart) / 1000);
|
||||
const role = activeRole();
|
||||
const rname = role?.label || 'Chat';
|
||||
const secs = Math.floor((Date.now() - _runStart) / 1000);
|
||||
const entry = activeChatModel();
|
||||
const useOrch = toolsEnabled && current_mode !== 'note';
|
||||
const model = useOrch
|
||||
const model = useOrch
|
||||
? (orchestratorModel || '(server default)') + ' (tool loop)'
|
||||
: (role?.model_label || '(server default)');
|
||||
stopBtn.title = `Running: ${rname} · ${model}\nElapsed: ${secs}s — click to cancel`;
|
||||
: (entry?.label || '(server default)');
|
||||
stopBtn.title = `Running: Chat · ${model}\nElapsed: ${secs}s — click to cancel`;
|
||||
}
|
||||
tick();
|
||||
_runTimer = setInterval(tick, 1000);
|
||||
@@ -469,23 +467,24 @@
|
||||
document.addEventListener('click', () => personaDropEl.classList.remove('open'));
|
||||
}
|
||||
|
||||
// ── Role toggle ──────────────────────────────────────────────
|
||||
// Cycles through roles that have a primary model assigned (excluding orchestrator).
|
||||
// Sends chat_role ("chat"|"coder"|"research"|...) in chat requests.
|
||||
// Falls back to "chat" when no roles are configured in the registry.
|
||||
// ── Model toggle (Phase 3) ───────────────────────────────────
|
||||
// Cycles through the chat role's configured slot models (primary → backup_1 → …).
|
||||
// Shows the model label on the button; sends slot + chat_role:"chat" in requests.
|
||||
// Falls back to "chat" / no slot when no models are configured.
|
||||
|
||||
const TYPE_CLASS = { claude_cli: '', gemini_api: 'mem-on', gemini_cli: 'mem-on', local_openai: 'local-on' };
|
||||
const backendModelHint = document.getElementById('backend-model-hint');
|
||||
|
||||
let availableRoles = []; // [{role, label, model_label, type}] from /backend
|
||||
let roleIdx = 0;
|
||||
let orchestratorModel = null; // label of the orchestrator-role model
|
||||
let chatModels = []; // [{slot, label, type}] for chat-role slots
|
||||
let availableRoles = []; // [{role, label, model_label, type}] — kept for banner check
|
||||
let modelIdx = 0;
|
||||
let orchestratorModel = null;
|
||||
|
||||
function activeRole() {
|
||||
return availableRoles.length > 0 ? availableRoles[roleIdx] : null;
|
||||
function activeChatModel() {
|
||||
return chatModels.length > 0 ? chatModels[modelIdx] : null;
|
||||
}
|
||||
|
||||
function setRoleToggleUI(entry) {
|
||||
function setModelToggleUI(entry) {
|
||||
if (!entry) {
|
||||
backendToggle.textContent = 'chat';
|
||||
backendToggle.className = 'ctx-btn';
|
||||
@@ -493,19 +492,16 @@
|
||||
backendToggle.textContent = entry.label;
|
||||
backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || '');
|
||||
}
|
||||
if (backendModelHint) {
|
||||
const hint = entry?.model_label || '';
|
||||
backendModelHint.textContent = hint;
|
||||
backendModelHint.style.display = hint ? '' : 'none';
|
||||
}
|
||||
if (backendModelHint) backendModelHint.style.display = 'none';
|
||||
updateSendBtnTitle();
|
||||
}
|
||||
|
||||
fetch('/backend').then(r => r.json()).then(d => {
|
||||
chatModels = d.chat_models || [];
|
||||
availableRoles = d.available_roles || [];
|
||||
orchestratorModel = d.orchestrator_model || null;
|
||||
roleIdx = 0;
|
||||
setRoleToggleUI(availableRoles[0] || null);
|
||||
modelIdx = 0;
|
||||
setModelToggleUI(chatModels[0] || null);
|
||||
_maybeShowNoBanner(availableRoles);
|
||||
});
|
||||
|
||||
@@ -527,17 +523,104 @@
|
||||
style="background:none;border:none;color:#78350f;cursor:pointer;font-size:1rem;line-height:1;padding:0 0.2rem;"
|
||||
title="Dismiss">✕</button>
|
||||
`;
|
||||
// Insert at the top of #chat-col (or body if not found)
|
||||
const col = document.getElementById('chat-col') || document.body.firstElementChild;
|
||||
col.insertBefore(banner, col.firstChild);
|
||||
}
|
||||
|
||||
backendToggle.addEventListener('click', () => {
|
||||
if (availableRoles.length <= 1) return;
|
||||
roleIdx = (roleIdx + 1) % availableRoles.length;
|
||||
const entry = availableRoles[roleIdx];
|
||||
setRoleToggleUI(entry);
|
||||
addMessage('system', `Role: ${entry.label} · ${entry.model_label}`);
|
||||
if (chatModels.length <= 1) return;
|
||||
modelIdx = (modelIdx + 1) % chatModels.length;
|
||||
const entry = chatModels[modelIdx];
|
||||
setModelToggleUI(entry);
|
||||
addMessage('system', `Model: ${entry.label}`);
|
||||
});
|
||||
|
||||
// ── File attachment ──────────────────────────────────────────
|
||||
const attachBtn = document.getElementById('attach-btn');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const attachRow = document.getElementById('attachment-row');
|
||||
const attachName = document.getElementById('attachment-name');
|
||||
const attachClear = document.getElementById('attachment-clear');
|
||||
const attachThumb = document.getElementById('attachment-thumb');
|
||||
|
||||
const _IMG_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||
const _TXT_EXTS = new Set(['.md','.txt','.py','.js','.ts','.jsx','.tsx','.json','.yaml','.yml','.toml','.html','.css','.sh','.csv','.xml','.rs','.go','.java','.c','.cpp','.h','.rb','.php','.swift','.kt','.sql','.env','.ini','.cfg','.log']);
|
||||
const MAX_IMAGE_B = 5 * 1024 * 1024; // 5 MB
|
||||
const MAX_TEXT_B = 100 * 1024; // 100 KB
|
||||
|
||||
let _pendingAttach = null; // {type:'image'|'text', filename, mime_type, data}
|
||||
|
||||
function _isTextFile(file) {
|
||||
if (file.type.startsWith('text/') || file.type === 'application/json') return true;
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||
return _TXT_EXTS.has(ext);
|
||||
}
|
||||
|
||||
function _langHint(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const m = {py:'python',js:'javascript',ts:'typescript',jsx:'jsx',tsx:'tsx',json:'json',yaml:'yaml',yml:'yaml',toml:'toml',html:'html',css:'css',sh:'bash',md:'markdown',rs:'rust',go:'go',java:'java',c:'c',cpp:'cpp',h:'c',rb:'ruby',php:'php',swift:'swift',kt:'kotlin',sql:'sql'};
|
||||
return m[ext] || '';
|
||||
}
|
||||
|
||||
function clearAttachment() {
|
||||
_pendingAttach = null;
|
||||
fileInput.value = '';
|
||||
attachRow.style.display = 'none';
|
||||
if (attachThumb) { attachThumb.src = ''; attachThumb.style.display = 'none'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the pending attachment into send-ready values.
|
||||
* - Text files: inject file content as a fenced code block in the message.
|
||||
* displayText = serverText = injected content (what the model sees).
|
||||
* - Images: keep text separate; pass image as payloadAttachment for vision APIs.
|
||||
* serverText includes a 📎 filename note for non-vision backends.
|
||||
*/
|
||||
function _resolveAttachment(inputText) {
|
||||
if (!_pendingAttach) return { displayText: inputText, serverText: inputText, payloadAttachment: null };
|
||||
const { type, filename, mime_type, data } = _pendingAttach;
|
||||
if (type === 'text') {
|
||||
const lang = _langHint(filename);
|
||||
const block = `📎 ${filename}\n\`\`\`${lang}\n${data.trimEnd()}\n\`\`\``;
|
||||
const serverText = inputText ? `${inputText}\n\n${block}` : block;
|
||||
return { displayText: serverText, serverText, payloadAttachment: null };
|
||||
}
|
||||
// Image
|
||||
const note = `📎 ${filename}`;
|
||||
const displayText = inputText ? `${inputText}\n${note}` : note;
|
||||
return { displayText, serverText: displayText, payloadAttachment: { filename, mime_type, data } };
|
||||
}
|
||||
|
||||
attachBtn.addEventListener('click', () => fileInput.click());
|
||||
attachClear.addEventListener('click', clearAttachment);
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
fileInput.value = ''; // reset so the same file can be re-selected
|
||||
|
||||
const isImg = _IMG_TYPES.has(file.type);
|
||||
const isTxt = !isImg && _isTextFile(file);
|
||||
|
||||
if (!isImg && !isTxt) { showToast('Unsupported file type'); return; }
|
||||
if (isImg && file.size > MAX_IMAGE_B) { showToast('Image too large (max 5 MB)'); return; }
|
||||
if (isTxt && file.size > MAX_TEXT_B) { showToast('Text file too large (max 100 KB)'); return; }
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
_pendingAttach = { type: isImg ? 'image' : 'text', filename: file.name, mime_type: file.type || 'text/plain', data: e.target.result };
|
||||
attachName.textContent = file.name;
|
||||
if (isImg && attachThumb) {
|
||||
attachThumb.src = e.target.result;
|
||||
attachThumb.style.display = 'block';
|
||||
attachRow.querySelector('#attachment-icon').style.display = 'none';
|
||||
} else if (attachThumb) {
|
||||
attachThumb.style.display = 'none';
|
||||
attachRow.querySelector('#attachment-icon').style.display = '';
|
||||
}
|
||||
attachRow.style.display = 'flex';
|
||||
};
|
||||
isImg ? reader.readAsDataURL(file) : reader.readAsText(file);
|
||||
});
|
||||
|
||||
// ── Sessions panel ───────────────────────────────────────────
|
||||
@@ -693,19 +776,53 @@
|
||||
editBtn.onclick = enterEditMode;
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────
|
||||
delBtn.addEventListener('click', async (e) => {
|
||||
delBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' });
|
||||
if (sessionId === s.session_id) {
|
||||
sessionId = null;
|
||||
clear_stored_session();
|
||||
currentHistory = [];
|
||||
messagesEl.innerHTML = '';
|
||||
sessionEl.textContent = '';
|
||||
showToast('Session deleted');
|
||||
|
||||
// Swap row content for inline confirm
|
||||
editBtn.hidden = true;
|
||||
bodyEl.hidden = true;
|
||||
delBtn.hidden = true;
|
||||
|
||||
const confirmRow = document.createElement('div');
|
||||
confirmRow.className = 'session-confirm-row';
|
||||
confirmRow.innerHTML =
|
||||
'<span class="session-confirm-label">Delete this session?</span>';
|
||||
|
||||
const yesBtn = document.createElement('button');
|
||||
yesBtn.className = 'session-confirm-yes';
|
||||
yesBtn.textContent = 'Delete';
|
||||
|
||||
const noBtn = document.createElement('button');
|
||||
noBtn.className = 'session-confirm-no';
|
||||
noBtn.textContent = 'Cancel';
|
||||
|
||||
confirmRow.append(yesBtn, noBtn);
|
||||
item.appendChild(confirmRow);
|
||||
|
||||
function cancelConfirm() {
|
||||
confirmRow.remove();
|
||||
editBtn.hidden = false;
|
||||
bodyEl.hidden = false;
|
||||
delBtn.hidden = false;
|
||||
}
|
||||
const res = await fetch(`/sessions?${_fileParams}`);
|
||||
renderPanel((await res.json()).sessions);
|
||||
|
||||
noBtn.addEventListener('click', (e) => { e.stopPropagation(); cancelConfirm(); });
|
||||
|
||||
yesBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' });
|
||||
if (sessionId === s.session_id) {
|
||||
sessionId = null;
|
||||
clear_stored_session();
|
||||
currentHistory = [];
|
||||
messagesEl.innerHTML = '';
|
||||
sessionEl.textContent = '';
|
||||
showToast('Session deleted');
|
||||
}
|
||||
const res = await fetch(`/sessions?${_fileParams}`);
|
||||
renderPanel((await res.json()).sessions);
|
||||
});
|
||||
});
|
||||
|
||||
sessionsPanel.appendChild(item);
|
||||
@@ -901,7 +1018,20 @@
|
||||
delBtn.className = 'msg-act-btn del';
|
||||
delBtn.innerHTML = icon_html('trash-2', 12) + ' del';
|
||||
delBtn.addEventListener('click', () => {
|
||||
deleteMsg(wrapper);
|
||||
actionsDiv.innerHTML = '';
|
||||
|
||||
const yesBtn = document.createElement('button');
|
||||
yesBtn.className = 'msg-act-btn del';
|
||||
yesBtn.textContent = 'confirm delete';
|
||||
yesBtn.addEventListener('click', () => deleteMsg(wrapper));
|
||||
|
||||
const noBtn = document.createElement('button');
|
||||
noBtn.className = 'msg-act-btn';
|
||||
noBtn.textContent = 'cancel';
|
||||
noBtn.addEventListener('click', () =>
|
||||
attachHistoryControls(msgDiv, parseInt(wrapper.dataset.histIdx)));
|
||||
|
||||
actionsDiv.append(yesBtn, noBtn);
|
||||
});
|
||||
|
||||
actionsDiv.appendChild(editBtn);
|
||||
@@ -1266,8 +1396,8 @@
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || activeController) return;
|
||||
const rawText = inputEl.value.trim();
|
||||
if ((!rawText && !_pendingAttach) || activeController) return;
|
||||
|
||||
const wasNewSession = !sessionId;
|
||||
|
||||
@@ -1281,10 +1411,12 @@
|
||||
activeController = new AbortController();
|
||||
|
||||
const isOtr = current_mode === 'otr';
|
||||
const { displayText, serverText, payloadAttachment } = _resolveAttachment(rawText);
|
||||
clearAttachment();
|
||||
|
||||
const userHistIdx = currentHistory.length;
|
||||
currentHistory.push({ role: 'user', content: text });
|
||||
const userMsgDiv = addMessage('user', text);
|
||||
currentHistory.push({ role: 'user', content: serverText });
|
||||
const userMsgDiv = addMessage('user', displayText);
|
||||
attachHistoryControls(userMsgDiv, userHistIdx);
|
||||
if (isOtr) setMessageMeta(userMsgDiv, {otr: true});
|
||||
scrollToBottom();
|
||||
@@ -1292,16 +1424,18 @@
|
||||
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
|
||||
|
||||
const payload = {
|
||||
message: text,
|
||||
message: serverText,
|
||||
session_id: sessionId,
|
||||
tier: currentTier,
|
||||
include_long: memLong,
|
||||
include_mid: memMid,
|
||||
include_short: memShort,
|
||||
off_record: isOtr,
|
||||
chat_role: activeRole()?.role || 'chat',
|
||||
chat_role: 'chat',
|
||||
slot: activeChatModel()?.slot || null,
|
||||
user: CORTEX_USER,
|
||||
persona: CORTEX_PERSONA,
|
||||
...(payloadAttachment ? { attachment: payloadAttachment } : {}),
|
||||
};
|
||||
|
||||
await _doSend(payload, thinkingDiv, wasNewSession);
|
||||
@@ -1330,7 +1464,8 @@
|
||||
include_mid: memMid,
|
||||
include_short: memShort,
|
||||
off_record: current_mode === 'otr',
|
||||
chat_role: activeRole()?.role || 'chat',
|
||||
chat_role: 'chat',
|
||||
slot: activeChatModel()?.slot || null,
|
||||
user: CORTEX_USER,
|
||||
persona: CORTEX_PERSONA,
|
||||
}),
|
||||
@@ -1465,8 +1600,8 @@
|
||||
}
|
||||
|
||||
async function sendOrchestrate() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || activeController) return;
|
||||
const rawText = inputEl.value.trim();
|
||||
if ((!rawText && !_pendingAttach) || activeController) return;
|
||||
|
||||
inputEl.value = '';
|
||||
syncHeight();
|
||||
@@ -1477,13 +1612,16 @@
|
||||
|
||||
activeController = new AbortController();
|
||||
|
||||
currentHistory.push({ role: 'user', content: text });
|
||||
const userMsgDiv = addMessage('user', text);
|
||||
const { displayText, serverText } = _resolveAttachment(rawText);
|
||||
clearAttachment();
|
||||
|
||||
currentHistory.push({ role: 'user', content: serverText });
|
||||
const userMsgDiv = addMessage('user', displayText);
|
||||
scrollToBottom();
|
||||
|
||||
const thinkingDiv = addMessage('assistant thinking', '⚡ working…');
|
||||
|
||||
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
|
||||
await _doOrchestrate(serverText, thinkingDiv, userMsgDiv);
|
||||
|
||||
activeController = null;
|
||||
setProcessing(false);
|
||||
|
||||
172
cortex/static/crons.html
Normal file
172
cortex/static/crons.html
Normal file
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cortex — Schedules</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
corePlugins: { preflight: false },
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pg: {
|
||||
bg: 'var(--pg-bg)',
|
||||
surface: 'var(--pg-surface)',
|
||||
border: 'var(--pg-border)',
|
||||
text: 'var(--pg-text)',
|
||||
muted: 'var(--pg-muted)',
|
||||
dim: 'var(--pg-dim)',
|
||||
dimmer: 'var(--pg-dimmer)',
|
||||
bright: 'var(--pg-bright)',
|
||||
accent: 'var(--pg-accent)',
|
||||
action: 'var(--pg-action)',
|
||||
}
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/pg.css">
|
||||
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||
<style>
|
||||
/* ── Server-generated table + badges ── */
|
||||
.cron-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 0.82rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.cron-table th {
|
||||
text-align: left; padding: 0.4rem 0.6rem;
|
||||
border-bottom: 2px solid var(--pg-border);
|
||||
color: var(--pg-muted); font-weight: 600; font-size: 0.75rem;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.cron-table td {
|
||||
padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--pg-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cron-table tr:last-child td { border-bottom: none; }
|
||||
.cron-table tr:hover td { background: var(--pg-hover); }
|
||||
|
||||
.badge {
|
||||
display: inline-block; padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px; font-size: 0.72rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.03em;
|
||||
}
|
||||
.badge-enabled { background: color-mix(in srgb, var(--pg-accent) 18%, transparent); color: var(--pg-accent); }
|
||||
.badge-paused { background: color-mix(in srgb, var(--pg-muted) 18%, transparent); color: var(--pg-muted); }
|
||||
.badge-remind { background: color-mix(in srgb, #a78bfa 15%, transparent); color: #a78bfa; }
|
||||
.badge-note { background: color-mix(in srgb, #60a5fa 15%, transparent); color: #60a5fa; }
|
||||
.badge-message { background: color-mix(in srgb, #34d399 15%, transparent); color: #34d399; }
|
||||
.badge-brief { background: color-mix(in srgb, #fb923c 15%, transparent); color: #fb923c; }
|
||||
.badge-task { background: color-mix(in srgb, #f472b6 15%, transparent); color: #f472b6; }
|
||||
|
||||
.cron-actions { display: flex; gap: 0.35rem; }
|
||||
.btn-cron {
|
||||
padding: 0.2rem 0.55rem; border-radius: 4px; border: 1px solid var(--pg-border);
|
||||
background: transparent; color: var(--pg-muted); font-size: 0.75rem; cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-cron:hover { border-color: var(--pg-accent); color: var(--pg-accent); }
|
||||
.btn-cron-del { color: var(--pg-dimmer); }
|
||||
.btn-cron-del:hover { border-color: #ef4444; color: #ef4444; }
|
||||
|
||||
.payload-cell {
|
||||
max-width: 240px; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; color: var(--pg-dimmer);
|
||||
}
|
||||
|
||||
.persona-group-label {
|
||||
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--pg-dimmer); margin: 1.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center; padding: 2rem 1rem;
|
||||
color: var(--pg-dimmer); font-size: 0.85rem;
|
||||
border: 1px dashed var(--pg-border); border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="page-nav">
|
||||
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<a href="/settings/models" class="nav-link">Models</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||
<a href="/settings/tools" class="nav-link">Tools</a>
|
||||
<a href="/settings/crons" class="nav-link active">Schedules</a>
|
||||
{{ integrations_nav }}
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
<div class="page-wrap">
|
||||
<h1 class="page-title">Schedules</h1>
|
||||
<p class="page-subtitle">Recurring jobs — reminders, notes, briefings, and agentic tasks.</p>
|
||||
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<!-- Edit form (shown only when editing) -->
|
||||
{{ edit_html }}
|
||||
|
||||
<!-- Cron list -->
|
||||
{{ cron_list_html }}
|
||||
|
||||
<!-- Add new schedule -->
|
||||
<div class="section">
|
||||
<h2>Add schedule</h2>
|
||||
<form method="POST" action="/settings/crons/add">
|
||||
<div class="grid grid-cols-2 gap-x-3">
|
||||
<div class="field">
|
||||
<label for="add_persona">Persona</label>
|
||||
<select id="add_persona" name="persona">
|
||||
{{ persona_options }}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="add_job_type">Type</label>
|
||||
<select id="add_job_type" name="job_type">
|
||||
<option value="remind">remind — append to REMINDERS.md</option>
|
||||
<option value="note">note — append to SCRATCH.md</option>
|
||||
<option value="message">message — send payload as-is</option>
|
||||
<option value="brief">brief — LLM response, no tools</option>
|
||||
<option value="task">task — full orchestrator tool loop</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="add_label">Label</label>
|
||||
<input type="text" id="add_label" name="label"
|
||||
placeholder="Monday morning summary"
|
||||
required autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="add_schedule">Schedule</label>
|
||||
<input type="text" id="add_schedule" name="schedule"
|
||||
placeholder="weekly:mon:08:00"
|
||||
required autocomplete="off" spellcheck="false">
|
||||
<p class="hint">
|
||||
hourly · daily · daily:HH:MM · weekly:DOW · weekly:DOW:HH:MM ·
|
||||
monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM
|
||||
</p>
|
||||
</div>
|
||||
<div class="field col-span-2">
|
||||
<label for="add_payload">Payload / prompt</label>
|
||||
<textarea id="add_payload" name="payload" rows="3"
|
||||
placeholder="Check my open tasks and send a summary." required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Add schedule</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,38 +8,40 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
corePlugins: { preflight: false },
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pg: {
|
||||
bg: 'var(--pg-bg)',
|
||||
surface: 'var(--pg-surface)',
|
||||
border: 'var(--pg-border)',
|
||||
text: 'var(--pg-text)',
|
||||
muted: 'var(--pg-muted)',
|
||||
dim: 'var(--pg-dim)',
|
||||
dimmer: 'var(--pg-dimmer)',
|
||||
bright: 'var(--pg-bright)',
|
||||
accent: 'var(--pg-accent)',
|
||||
action: 'var(--pg-action)',
|
||||
}
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/pg.css">
|
||||
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||
<style>
|
||||
.page { max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
|
||||
|
||||
/* ── Header ── */
|
||||
header { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--pg-border); }
|
||||
header h1 { font-size: 1.5rem; font-weight: 700; color: var(--pg-accent); }
|
||||
header p { font-size: 0.85rem; color: var(--pg-muted); margin-top: 0.25rem; }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.tab-bar {
|
||||
display: flex; gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem; font-weight: 500;
|
||||
color: var(--pg-dim);
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn:hover { color: var(--pg-bright); }
|
||||
.tab-btn.active { color: var(--pg-accent); border-bottom-color: var(--pg-accent); }
|
||||
|
||||
/* ── Tab panels (JS-toggled display) ── */
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ── Content ── */
|
||||
/* ── Dynamically-rendered markdown content ── */
|
||||
.help-body { line-height: 1.7; }
|
||||
|
||||
details {
|
||||
@@ -83,8 +85,6 @@
|
||||
.help-body pre { background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px; padding: 0.75rem 1rem; overflow-x: auto; margin: 0.5rem 0; }
|
||||
.help-body pre code { background: none; border: none; padding: 0; font-size: 0.85em; color: var(--pg-muted); }
|
||||
.help-body hr { border: none; border-top: 1px solid var(--pg-border); margin: 0.5rem 0; }
|
||||
|
||||
.empty-state { color: var(--pg-dim); font-size: 0.9rem; padding: 2rem 0; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -92,28 +92,49 @@
|
||||
<a id="nav-chat" href="/" class="nav-link">← Chat</a>
|
||||
<a href="/help" class="nav-link active">Help</a>
|
||||
<a href="/settings" class="nav-link" id="nav-settings">Settings</a>
|
||||
<a href="/settings/models" class="nav-link">Models</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||
<a href="/settings/tools" class="nav-link">Tools</a>
|
||||
<a href="/settings/crons" class="nav-link">Schedules</a>
|
||||
{{ integrations_nav }}
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Help & Reference</h1>
|
||||
<p id="persona-label"></p>
|
||||
</header>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-6 py-8 pb-16">
|
||||
<div class="mb-6 pb-4 border-b border-pg-border">
|
||||
<h1 class="text-xl font-bold text-pg-accent">Help & Reference</h1>
|
||||
<p id="persona-label" class="text-xs text-pg-muted mt-1"></p>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="ui">UI Guide</button>
|
||||
<button class="tab-btn" data-tab="tools">Tools</button>
|
||||
<button class="tab-btn" data-tab="persona" id="tab-btn-persona">Persona</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-ui" class="tab-panel active"><div class="help-body"><p class="empty-state">Loading…</p></div></div>
|
||||
<div id="tab-tools" class="tab-panel"> <div class="help-body"><p class="empty-state">Loading…</p></div></div>
|
||||
<div id="tab-persona" class="tab-panel"> <div class="help-body"><p class="empty-state">Loading…</p></div></div>
|
||||
<div id="tab-ui" class="tab-panel active"><div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
|
||||
<div id="tab-tools" class="tab-panel"> <div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
|
||||
<div id="tab-persona" class="tab-panel"> <div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tab-bar {
|
||||
display: flex; gap: 0.25rem; margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 0.45rem 1rem; font-size: 0.85rem; font-weight: 500;
|
||||
color: var(--pg-dim); background: none; border: none;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tab-btn:hover { color: var(--pg-bright); }
|
||||
.tab-btn.active { color: var(--pg-accent); border-bottom-color: var(--pg-accent); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const cfg = window.HELP_CONFIG || {};
|
||||
const user = cfg.user || 'scott';
|
||||
@@ -177,20 +198,20 @@
|
||||
}
|
||||
|
||||
// ── Load all three tabs in parallel ─────────────────────────────
|
||||
const UI_OPEN = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
|
||||
const UI_OPEN = new Set(['Getting Started', 'Chat', 'Sessions', 'Model Registry']);
|
||||
|
||||
async function loadAll() {
|
||||
// UI Guide
|
||||
fetch('/static/HELP.md')
|
||||
.then(r => r.ok ? r.text() : Promise.reject(r.status))
|
||||
.then(md => render('tab-ui', md, false, UI_OPEN))
|
||||
.catch(e => { document.querySelector('#tab-ui .help-body').innerHTML = `<p class="empty-state">Failed to load: ${e}</p>`; });
|
||||
.catch(e => { document.querySelector('#tab-ui .help-body').innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">Failed to load: ${e}</p>`; });
|
||||
|
||||
// Tools
|
||||
fetch('/static/TOOLS.md')
|
||||
.then(r => r.ok ? r.text() : Promise.reject(r.status))
|
||||
.then(md => render('tab-tools', md, true, null))
|
||||
.catch(e => { document.querySelector('#tab-tools .help-body').innerHTML = `<p class="empty-state">Failed to load: ${e}</p>`; });
|
||||
.catch(e => { document.querySelector('#tab-tools .help-body').innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">Failed to load: ${e}</p>`; });
|
||||
|
||||
// Persona-specific HELP.md
|
||||
const personaPanel = document.querySelector('#tab-persona .help-body');
|
||||
@@ -202,13 +223,13 @@
|
||||
if (content) {
|
||||
render('tab-persona', content, true, null);
|
||||
} else {
|
||||
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet. Edit <code>HELP.md</code> in the Files panel to add them.</p>`;
|
||||
personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet. Edit <code>HELP.md</code> in the Files panel to add them.</p>`;
|
||||
}
|
||||
} else {
|
||||
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet.</p>`;
|
||||
personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet.</p>`;
|
||||
}
|
||||
} catch (_) {
|
||||
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet.</p>`;
|
||||
personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,19 @@
|
||||
<button id="note-vis-btn" title="Toggle note visibility (private / public)">prv</button>
|
||||
<!-- Tools toggle — routes through the orchestrator tool loop when active -->
|
||||
<button id="tools-toggle" title="Tools disabled — click to enable">⚡</button>
|
||||
<!-- Attach file — images (vision) or text/code files -->
|
||||
<button id="attach-btn" title="Attach image or text file">📎</button>
|
||||
<input type="file" id="file-input" style="display:none"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,text/plain,text/markdown,.md,.txt,.py,.js,.ts,.jsx,.tsx,.json,.yaml,.yml,.toml,.html,.css,.sh,.csv,.xml,.rs,.go,.java,.c,.cpp,.h,.rb,.php,.swift,.kt,.sql">
|
||||
</div>
|
||||
<!-- Attachment preview — shown when a file is pending -->
|
||||
<div id="attachment-row" style="display:none">
|
||||
<div id="attachment-preview">
|
||||
<img id="attachment-thumb" alt="" style="display:none">
|
||||
<span id="attachment-icon">📎</span>
|
||||
<span id="attachment-name"></span>
|
||||
<button id="attachment-clear" title="Remove attachment">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="input" rows="1" placeholder="Message…" autofocus></textarea>
|
||||
<div id="send-col">
|
||||
|
||||
134
cortex/static/integrations.html
Normal file
134
cortex/static/integrations.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cortex — Integrations</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
corePlugins: { preflight: false },
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pg: {
|
||||
bg: 'var(--pg-bg)',
|
||||
surface: 'var(--pg-surface)',
|
||||
border: 'var(--pg-border)',
|
||||
text: 'var(--pg-text)',
|
||||
muted: 'var(--pg-muted)',
|
||||
dim: 'var(--pg-dim)',
|
||||
dimmer: 'var(--pg-dimmer)',
|
||||
bright: 'var(--pg-bright)',
|
||||
accent: 'var(--pg-accent)',
|
||||
action: 'var(--pg-action)',
|
||||
}
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/pg.css">
|
||||
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||
<style>
|
||||
details.channel-block summary::-webkit-details-marker { display: none; }
|
||||
details.channel-block summary::before {
|
||||
content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer);
|
||||
transition: transform 0.15s; flex-shrink: 0;
|
||||
}
|
||||
details.channel-block[open] summary::before { transform: rotate(90deg); }
|
||||
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="page-nav">
|
||||
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<a href="/settings/models" class="nav-link">Models</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||
<a href="/settings/tools" class="nav-link">Tools</a>
|
||||
<a href="/settings/crons" class="nav-link">Schedules</a>
|
||||
<a href="/settings/integrations" class="nav-link active">Integrations</a>
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
<div class="page-wrap">
|
||||
<h1 class="page-title">Integrations</h1>
|
||||
<p class="page-subtitle">External service connections — admin only.</p>
|
||||
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<form method="POST" action="/settings/integrations">
|
||||
|
||||
<div class="section">
|
||||
<h2>Aether Platform Database</h2>
|
||||
<p class="section-note">
|
||||
Gives the orchestrator direct read-only access to the Aether MariaDB via the
|
||||
<code>ae_db_query</code>, <code>ae_db_describe</code>, and <code>ae_db_show_view</code> tools.
|
||||
Only SELECT, SHOW, DESCRIBE, and EXPLAIN are permitted — no writes possible.
|
||||
</p>
|
||||
|
||||
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
|
||||
{{ ae_db_host and 'open' or '' }}>
|
||||
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
|
||||
Connection
|
||||
</summary>
|
||||
<div class="px-4 pt-4 pb-2">
|
||||
<p class="text-xs text-pg-dimmer mb-4 -mt-1 leading-relaxed">
|
||||
Use the same credentials as
|
||||
<code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1 text-xs">agents_sync/mcp/scripts/sql_inspector.py</code>.
|
||||
Leave the password blank to keep the stored value.
|
||||
</p>
|
||||
<div class="grid grid-cols-[1fr_7rem] gap-3 items-start">
|
||||
<div class="field">
|
||||
<label for="ae_db_host">Host</label>
|
||||
<input type="text" id="ae_db_host" name="ae_db_host"
|
||||
value="{{ ae_db_host }}"
|
||||
placeholder="192.168.64.5"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ae_db_port">Port</label>
|
||||
<input type="number" id="ae_db_port" name="ae_db_port"
|
||||
value="{{ ae_db_port }}"
|
||||
placeholder="3306" min="1" max="65535"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ae_db_name">Database name</label>
|
||||
<input type="text" id="ae_db_name" name="ae_db_name"
|
||||
value="{{ ae_db_name }}"
|
||||
placeholder="aether_dev"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ae_db_user">Username</label>
|
||||
<input type="text" id="ae_db_user" name="ae_db_user"
|
||||
value="{{ ae_db_user }}"
|
||||
placeholder="aether_dev"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ae_db_password">Password</label>
|
||||
<input type="password" id="ae_db_password" name="ae_db_password"
|
||||
value=""
|
||||
placeholder="Leave blank to keep existing value"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save integrations</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,38 +7,36 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
corePlugins: { preflight: false },
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pg: {
|
||||
bg: 'var(--pg-bg)',
|
||||
surface: 'var(--pg-surface)',
|
||||
border: 'var(--pg-border)',
|
||||
text: 'var(--pg-text)',
|
||||
muted: 'var(--pg-muted)',
|
||||
dim: 'var(--pg-dim)',
|
||||
dimmer: 'var(--pg-dimmer)',
|
||||
bright: 'var(--pg-bright)',
|
||||
accent: 'var(--pg-accent)',
|
||||
action: 'var(--pg-action)',
|
||||
}
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/pg.css">
|
||||
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||
<style>
|
||||
/* ── Test action buttons ── */
|
||||
.test-btn-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
||||
.test-btn {
|
||||
flex: 1; padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--pg-border); border-radius: 6px;
|
||||
background: var(--pg-bg); color: var(--pg-text);
|
||||
font-size: 0.85rem; font-weight: 500; cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s; text-align: center;
|
||||
}
|
||||
.test-btn:hover { border-color: var(--pg-action); color: var(--pg-accent); }
|
||||
.test-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.test-result {
|
||||
margin-top: 0.75rem; padding: 0.6rem 0.8rem; border-radius: 6px;
|
||||
font-size: 0.82rem; line-height: 1.5; display: none;
|
||||
}
|
||||
.test-result.ok { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
|
||||
.test-result.err { background: rgba(248,113,113,0.1); color: #f87171; border: 1px solid rgba(248,113,113,0.25); }
|
||||
|
||||
/* ── Channel config collapsible blocks ── */
|
||||
details.channel-block {
|
||||
border: 1px solid var(--pg-border); border-radius: 8px;
|
||||
margin-bottom: 0.75rem; overflow: hidden;
|
||||
}
|
||||
details.channel-block summary {
|
||||
padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600;
|
||||
color: var(--pg-muted); cursor: pointer; list-style: none;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
user-select: none; background: var(--pg-bg);
|
||||
}
|
||||
/* ── Channel collapsible arrow ── */
|
||||
details.channel-block summary::-webkit-details-marker { display: none; }
|
||||
details.channel-block summary::before {
|
||||
content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer);
|
||||
@@ -46,11 +44,9 @@
|
||||
}
|
||||
details.channel-block[open] summary::before { transform: rotate(90deg); }
|
||||
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
|
||||
.channel-block-body { padding: 1rem 1rem 0.25rem; }
|
||||
.channel-hint {
|
||||
font-size: 0.75rem; color: var(--pg-dimmer);
|
||||
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Test result feedback (JS-toggled display) ── */
|
||||
#test-result { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -58,14 +54,17 @@
|
||||
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<a href="/settings/models" class="nav-link">Models</a>
|
||||
<a href="/settings/notifications" class="nav-link active">Notifications</a>
|
||||
<a href="/settings/tools" class="nav-link">Tools</a>
|
||||
<a href="/settings/crons" class="nav-link">Schedules</a>
|
||||
{{ integrations_nav }}
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
<div class="page-wrap">
|
||||
<h1 class="page-title">Notifications</h1>
|
||||
<p class="page-subtitle">How Inara reaches out proactively — reminders, cron jobs, and memory digests.</p>
|
||||
<p class="page-subtitle">How your persona reaches out proactively — reminders, cron jobs, and memory digests.</p>
|
||||
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
@@ -88,8 +87,9 @@
|
||||
<p class="hint">Used for reminder alerts, distillation summaries, and cron job notifications.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="notification_email">Email address override
|
||||
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
|
||||
<label for="notification_email">
|
||||
Email address override
|
||||
<span class="font-normal text-pg-dim">(optional)</span>
|
||||
</label>
|
||||
<input type="email" id="notification_email" name="notification_email"
|
||||
value="{{ notify_email_override }}"
|
||||
@@ -108,12 +108,15 @@
|
||||
requires a Nextcloud username and app password.
|
||||
</p>
|
||||
|
||||
<details class="channel-block" {{ nc_url and 'open' or '' }}>
|
||||
<summary>Bot credentials (sending)</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
|
||||
{{ nc_url and 'open' or '' }}>
|
||||
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
|
||||
Bot credentials (sending)
|
||||
</summary>
|
||||
<div class="px-4 pt-4 pb-2">
|
||||
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
|
||||
Set these up in your Nextcloud Talk room → Bot settings.
|
||||
See the <a href="/help" style="color:var(--pg-accent);">setup guide</a> for step-by-step instructions.
|
||||
See the <a href="/help" class="text-pg-accent">setup guide</a> for step-by-step instructions.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="nc_url">Nextcloud URL</label>
|
||||
@@ -141,10 +144,13 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="channel-block" {{ nc_username and 'open' or '' }}>
|
||||
<summary>API credentials (reading history)</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
|
||||
{{ nc_username and 'open' or '' }}>
|
||||
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
|
||||
API credentials (reading history)
|
||||
</summary>
|
||||
<div class="px-4 pt-4 pb-2">
|
||||
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
|
||||
Required for the <code>nc_talk_history</code> orchestrator tool.
|
||||
Generate an app password in Nextcloud → Settings → Security → App passwords.
|
||||
</p>
|
||||
@@ -170,15 +176,18 @@
|
||||
<div class="section">
|
||||
<h2>Home Assistant</h2>
|
||||
<p class="section-note">
|
||||
Receive events from HA automations and let Inara call the HA REST API
|
||||
Receive events from HA automations and let your persona call the HA REST API
|
||||
(read states, control devices). Webhook ID is the shared secret used in your
|
||||
HA <code>rest_command</code> URL.
|
||||
</p>
|
||||
|
||||
<details class="channel-block" {{ ha_url and 'open' or '' }}>
|
||||
<summary>Connection</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
|
||||
{{ ha_url and 'open' or '' }}>
|
||||
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
|
||||
Connection
|
||||
</summary>
|
||||
<div class="px-4 pt-4 pb-2">
|
||||
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
|
||||
HA URL and a Long-Lived Access Token (Profile → scroll to bottom →
|
||||
Long-Lived Access Tokens → Create Token).
|
||||
</p>
|
||||
@@ -199,10 +208,13 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="channel-block" {{ ha_webhook_id and 'open' or '' }}>
|
||||
<summary>Inbound webhook (HA → Cortex)</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
|
||||
{{ ha_webhook_id and 'open' or '' }}>
|
||||
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
|
||||
Inbound webhook (HA → Cortex)
|
||||
</summary>
|
||||
<div class="px-4 pt-4 pb-2">
|
||||
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
|
||||
The webhook ID is the shared secret in your HA <code>rest_command</code> URL.
|
||||
Your endpoint: <code>https://cortex.dgrzone.com/webhook/ha/{{ ha_username }}/<webhook_id></code>
|
||||
</p>
|
||||
@@ -214,6 +226,13 @@
|
||||
autocomplete="off" spellcheck="false">
|
||||
<p class="hint">Treat this like a password — use a long, random string.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="ha_tools" value="1" {{ ha_tools_checked }}>
|
||||
Enable orchestrator tools
|
||||
</label>
|
||||
<p class="hint">When checked, HA events trigger the full tool loop (research, home control, tasks). When unchecked, events get a direct LLM response — faster but no tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -226,10 +245,13 @@
|
||||
Incoming messages are handled separately via the Google Chat Add-on.
|
||||
</p>
|
||||
|
||||
<details class="channel-block" {{ gc_webhook and 'open' or '' }}>
|
||||
<summary>Outbound webhook</summary>
|
||||
<div class="channel-block-body">
|
||||
<p class="channel-hint">
|
||||
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
|
||||
{{ gc_webhook and 'open' or '' }}>
|
||||
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
|
||||
Outbound webhook
|
||||
</summary>
|
||||
<div class="px-4 pt-4 pb-2">
|
||||
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
|
||||
Create a webhook in your Google Chat space → Manage webhooks. Paste the full URL here.
|
||||
</p>
|
||||
<div class="field">
|
||||
@@ -243,7 +265,7 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">Save notification settings</button>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save notification settings</button>
|
||||
</form>
|
||||
|
||||
<!-- Test -->
|
||||
@@ -253,11 +275,14 @@
|
||||
Fire a notification via your configured channel or run the reminder check
|
||||
immediately — no need to wait for the daily 09:00 scheduler job.
|
||||
</p>
|
||||
<div class="test-btn-row">
|
||||
<button class="test-btn" id="btn-test-notify">Send Test Notification</button>
|
||||
<button class="test-btn" id="btn-check-reminders">Check Reminders Now</button>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<button class="flex-1 px-3 py-2.5 text-sm font-medium border border-pg-border rounded-md bg-pg-bg text-pg-text hover:border-pg-action hover:text-pg-accent transition-colors cursor-pointer disabled:opacity-50"
|
||||
id="btn-test-notify">Send Test Notification</button>
|
||||
<button class="flex-1 px-3 py-2.5 text-sm font-medium border border-pg-border rounded-md bg-pg-bg text-pg-text hover:border-pg-action hover:text-pg-accent transition-colors cursor-pointer disabled:opacity-50"
|
||||
id="btn-check-reminders">Check Reminders Now</button>
|
||||
</div>
|
||||
<div class="test-result" id="test-result"></div>
|
||||
<div id="test-result"
|
||||
class="mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -278,7 +303,9 @@
|
||||
|
||||
function showResult(ok, msg) {
|
||||
resultEl.textContent = msg;
|
||||
resultEl.className = 'test-result ' + (ok ? 'ok' : 'err');
|
||||
resultEl.className = ok
|
||||
? 'mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed bg-green-950 text-green-400 border border-green-800'
|
||||
: 'mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed bg-red-950 text-red-400 border border-red-800';
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ input, select, textarea {
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--pg-action); }
|
||||
input[readonly] { color: var(--pg-muted); cursor: default; }
|
||||
input[type="password"] { font-family: monospace; letter-spacing: 0.05em; }
|
||||
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
|
||||
|
||||
textarea {
|
||||
font-family: 'SF Mono', 'Fira Mono', 'Menlo', monospace;
|
||||
@@ -99,12 +100,12 @@ textarea {
|
||||
|
||||
/* ── Buttons ── */
|
||||
|
||||
/* Full-width primary form submit */
|
||||
/* Primary form submit */
|
||||
.btn-submit {
|
||||
width: 100%; padding: 0.7rem; margin-top: 0.25rem;
|
||||
padding: 0.6rem 1.5rem; margin-top: 0.25rem;
|
||||
background: var(--pg-action); border: none; border-radius: 6px;
|
||||
color: #fff; font-size: 1rem; font-weight: 600;
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
color: #fff; font-size: 0.9rem; font-weight: 600;
|
||||
cursor: pointer; transition: opacity 0.15s;
|
||||
}
|
||||
.btn-submit:hover { opacity: 0.88; }
|
||||
|
||||
|
||||
@@ -7,10 +7,36 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
corePlugins: { preflight: false },
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pg: {
|
||||
bg: 'var(--pg-bg)',
|
||||
surface: 'var(--pg-surface)',
|
||||
border: 'var(--pg-border)',
|
||||
text: 'var(--pg-text)',
|
||||
muted: 'var(--pg-muted)',
|
||||
dim: 'var(--pg-dim)',
|
||||
dimmer: 'var(--pg-dimmer)',
|
||||
bright: 'var(--pg-bright)',
|
||||
accent: 'var(--pg-accent)',
|
||||
action: 'var(--pg-action)',
|
||||
}
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/pg.css">
|
||||
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||
<style>
|
||||
/* ── Persona list ── */
|
||||
/* ── Server-generated persona list ── */
|
||||
.persona-list {
|
||||
list-style: none; display: flex; flex-direction: column;
|
||||
gap: 0.5rem; margin-top: 0.5rem;
|
||||
@@ -37,13 +63,8 @@
|
||||
border-color: var(--pg-action); font-size: 0.9rem;
|
||||
}
|
||||
.persona-rename-form .btn-save { padding: 0.3rem 0.75rem; font-size: 0.85rem; }
|
||||
.add-persona {
|
||||
display: inline-block; margin-top: 0.75rem;
|
||||
font-size: 0.8rem; color: var(--pg-muted); text-decoration: none;
|
||||
}
|
||||
.add-persona:hover { color: var(--pg-accent); }
|
||||
|
||||
/* ── Role badge ── */
|
||||
/* ── Server-generated role badge ── */
|
||||
.role-badge {
|
||||
display: inline-block; padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px; font-size: 0.78rem; font-weight: 600;
|
||||
@@ -58,26 +79,8 @@
|
||||
border: 1px solid var(--pg-border);
|
||||
}
|
||||
|
||||
/* ── OpenRouter quickstart warning card ── */
|
||||
#openrouter-quickstart {
|
||||
display: none; background: #1c1a0a; border: 1px solid #78350f;
|
||||
border-radius: 8px; padding: 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
#openrouter-quickstart .qs-title {
|
||||
font-size: 0.82rem; color: #fbbf24; font-weight: 600; margin-bottom: 0.4rem;
|
||||
}
|
||||
#openrouter-quickstart .qs-body {
|
||||
font-size: 0.8rem; color: #d97706; margin-bottom: 0.75rem; line-height: 1.5;
|
||||
}
|
||||
.action-link.action-link-amber {
|
||||
background: #92400e; color: #fef3c7; font-size: 0.85rem; padding: 0.5rem 0.9rem;
|
||||
}
|
||||
.action-link.action-link-amber:hover { opacity: 0.9; background: #78350f; }
|
||||
|
||||
/* ── Inline result feedback spans ── */
|
||||
.result-ok { display: none; margin-left: 0.75rem; font-size: 0.8rem; color: #4ade80; }
|
||||
|
||||
/* ── Usage table wrapper ── */
|
||||
/* ── JS-toggled states ── */
|
||||
#clear-ls-ok { display: none; margin-left: 0.75rem; font-size: 0.8rem; color: #4ade80; }
|
||||
.usage-wrap { overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -86,8 +89,11 @@
|
||||
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||
<a href="/settings" class="nav-link active">Settings</a>
|
||||
<a href="/settings/models" class="nav-link">Models</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||
<a href="/settings/tools" class="nav-link">Tools</a>
|
||||
<a href="/settings/crons" class="nav-link">Schedules</a>
|
||||
{{ integrations_nav }}
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
@@ -98,6 +104,21 @@
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<!-- OpenRouter quickstart (shown by JS when no model is configured) -->
|
||||
<div id="openrouter-quickstart"
|
||||
class="hidden rounded-xl border border-amber-800 bg-amber-950 p-4 mb-5">
|
||||
<p class="text-xs font-semibold text-amber-400 mb-1">⚡ You're on the server default model</p>
|
||||
<p class="text-xs text-amber-600 mb-3 leading-relaxed">
|
||||
You can chat now, but adding your own model gives you more choices, lets you pick
|
||||
role-specific models, and tracks your usage separately.
|
||||
OpenRouter is the easiest way to get started — one key, many models.
|
||||
</p>
|
||||
<a href="/setup/model"
|
||||
class="inline-block px-3 py-2 rounded-md bg-amber-900 text-amber-100 text-sm font-medium hover:bg-amber-800 transition-colors">
|
||||
Set up OpenRouter →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Account info -->
|
||||
<div class="section">
|
||||
<h2>Account</h2>
|
||||
@@ -154,7 +175,7 @@
|
||||
placeholder=".*@example\.com alice@example\.com"
|
||||
spellcheck="false">{{ email_allowlist }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-submit">Save allowlist</button>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -172,28 +193,10 @@
|
||||
placeholder="https://ha.dgrzone.com/api/webhook/ https://n8n.dgrzone.com/webhook/"
|
||||
spellcheck="false">{{ http_allowlist }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-submit">Save allowlist</button>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="section">
|
||||
<h2>Notifications</h2>
|
||||
<p class="section-note">
|
||||
Configure how Inara reaches out proactively — reminders, cron jobs, and memory digests.
|
||||
</p>
|
||||
<a href="/settings/notifications" class="action-link">Notification settings →</a>
|
||||
</div>
|
||||
|
||||
<!-- Tool Permissions → /settings/tools -->
|
||||
<div class="section">
|
||||
<h2>Tool Permissions</h2>
|
||||
<p class="section-note">
|
||||
Configure tool access, risk policy, and confirmation gate overrides on the Tools page.
|
||||
</p>
|
||||
<a href="/settings/tools" class="action-link">Tool settings →</a>
|
||||
</div>
|
||||
|
||||
<!-- Usage summary -->
|
||||
<div class="section" id="usage-section">
|
||||
<h2>Usage</h2>
|
||||
@@ -214,28 +217,7 @@
|
||||
theme, font size, and context tier. Does not sign you out.
|
||||
</p>
|
||||
<button type="button" id="clear-ls-btn" class="btn-secondary">Clear browser cache</button>
|
||||
<span id="clear-ls-ok" class="result-ok">Cleared.</span>
|
||||
</div>
|
||||
|
||||
<!-- Model Registry -->
|
||||
<div class="section">
|
||||
<h2>Model Registry</h2>
|
||||
|
||||
<div id="openrouter-quickstart">
|
||||
<p class="qs-title">⚡ You're on the server default model</p>
|
||||
<p class="qs-body">
|
||||
You can chat now, but adding your own model gives you more choices, lets you pick
|
||||
role-specific models, and tracks your usage separately.
|
||||
OpenRouter is the easiest way to get started — one key, many models.
|
||||
</p>
|
||||
<a href="/setup/model" class="action-link action-link-amber">Set up OpenRouter →</a>
|
||||
</div>
|
||||
|
||||
<p class="section-note">
|
||||
Configure AI providers (Anthropic, Google), local hosts (Open WebUI, Ollama, OpenRouter, etc.),
|
||||
and assign models to roles — chat, orchestrator, distill, and more.
|
||||
</p>
|
||||
<a href="/settings/models" class="action-link">Manage models →</a>
|
||||
<span id="clear-ls-ok">Cleared.</span>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
@@ -257,7 +239,7 @@
|
||||
<input type="password" id="confirm_password" name="confirm_password"
|
||||
autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-submit">Update password</button>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +251,9 @@
|
||||
Only unnamed sessions are affected — existing names are left alone.
|
||||
</p>
|
||||
<button type="button" id="backfill-names-btn" class="btn-secondary">Auto-name old sessions</button>
|
||||
<span id="backfill-names-ok" class="result-ok"></span>
|
||||
<span id="backfill-names-ok"
|
||||
class="ml-3 text-xs hidden"
|
||||
style="color:#4ade80"></span>
|
||||
</div>
|
||||
|
||||
<!-- Personas -->
|
||||
@@ -278,7 +262,10 @@
|
||||
<ul class="persona-list">
|
||||
{{ persona_items }}
|
||||
</ul>
|
||||
<a href="/setup/persona" class="add-persona">+ Add new persona</a>
|
||||
<a href="/setup/persona"
|
||||
class="inline-block mt-3 text-xs text-pg-muted hover:text-pg-accent transition-colors">
|
||||
+ Add new persona
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -315,7 +302,9 @@
|
||||
try {
|
||||
const d = await fetch('/backend').then(r => r.json());
|
||||
if ((d.available_roles || []).length === 0) {
|
||||
document.getElementById('openrouter-quickstart').style.display = 'block';
|
||||
const el = document.getElementById('openrouter-quickstart');
|
||||
el.classList.remove('hidden');
|
||||
el.style.display = 'block';
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
@@ -373,10 +362,12 @@
|
||||
const n = data.named ?? 0;
|
||||
ok.textContent = `Named ${n} session${n !== 1 ? 's' : ''}.`;
|
||||
ok.style.display = 'inline';
|
||||
ok.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
ok.textContent = 'Error — check console.';
|
||||
ok.style.color = '#f87171';
|
||||
ok.style.display = 'inline';
|
||||
ok.classList.remove('hidden');
|
||||
}
|
||||
btn.textContent = 'Auto-name old sessions';
|
||||
btn.disabled = false;
|
||||
|
||||
@@ -372,6 +372,35 @@
|
||||
}
|
||||
.session-save-btn:hover { opacity: 0.75; }
|
||||
|
||||
.session-confirm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.session-confirm-label {
|
||||
flex: 1;
|
||||
font-size: 0.78rem;
|
||||
color: #e06c75;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.session-confirm-yes, .session-confirm-no {
|
||||
background: none;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.session-confirm-yes { border-color: #e06c75; color: #e06c75; }
|
||||
.session-confirm-no { border-color: var(--muted); color: var(--muted); }
|
||||
.session-confirm-yes:hover, .session-confirm-no:hover { opacity: 0.75; }
|
||||
|
||||
.session-rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -832,6 +861,58 @@
|
||||
}
|
||||
#tools-toggle.local-on:hover { box-shadow: 0 0 10px var(--amber-glow); }
|
||||
|
||||
#attach-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-size: 0.95rem;
|
||||
padding: 3px 7px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
#attach-btn:hover { color: rgba(255,255,255,0.6); border-color: rgba(255,255,255,0.25); }
|
||||
|
||||
#attachment-row {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#attachment-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
#attachment-thumb {
|
||||
max-height: 2.4rem;
|
||||
max-width: 3.5rem;
|
||||
border-radius: 3px;
|
||||
object-fit: contain;
|
||||
}
|
||||
#attachment-name {
|
||||
color: var(--text-mid);
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#attachment-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0 0.15rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#attachment-clear:hover { color: var(--text); }
|
||||
|
||||
#input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
|
||||
@@ -7,42 +7,36 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
corePlugins: { preflight: false },
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pg: {
|
||||
bg: 'var(--pg-bg)',
|
||||
surface: 'var(--pg-surface)',
|
||||
border: 'var(--pg-border)',
|
||||
text: 'var(--pg-text)',
|
||||
muted: 'var(--pg-muted)',
|
||||
dim: 'var(--pg-dim)',
|
||||
dimmer: 'var(--pg-dimmer)',
|
||||
bright: 'var(--pg-bright)',
|
||||
accent: 'var(--pg-accent)',
|
||||
action: 'var(--pg-action)',
|
||||
}
|
||||
},
|
||||
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/pg.css">
|
||||
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
||||
<style>
|
||||
/* ── Policy cards (bordered sections on tools page) ── */
|
||||
.policy-card {
|
||||
background: var(--pg-surface); border: 1px solid var(--pg-border);
|
||||
border-radius: 0.75rem; padding: 1.25rem 1.5rem; margin-bottom: 1.75rem;
|
||||
}
|
||||
.policy-card h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; }
|
||||
.policy-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
.policy-label { font-size: 0.875rem; font-weight: 500; min-width: 6rem; }
|
||||
.policy-note { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.35rem; line-height: 1.5; }
|
||||
|
||||
/* Compact selects and inputs inside policy cards */
|
||||
.policy-card select, .policy-card input[type="text"] {
|
||||
padding: 0.4rem 0.65rem; font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Two-column layout for allow/deny textareas */
|
||||
.col-split { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
.col-half { flex: 1; min-width: 200px; }
|
||||
.col-half label { font-size: 0.8rem; font-weight: 600; margin-bottom: 0.35rem; }
|
||||
.col-half textarea {
|
||||
font-size: 0.82rem; border-radius: 0.375rem; padding: 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Save button (compact, not full-width) */
|
||||
.save-btn {
|
||||
background: var(--pg-action); color: #fff; border: none;
|
||||
border-radius: 0.5rem; padding: 0.5rem 1.4rem;
|
||||
font-size: 0.875rem; font-weight: 600; cursor: pointer;
|
||||
margin-top: 0.5rem; transition: opacity 0.15s;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.88; }
|
||||
|
||||
/* ── Tool table ── */
|
||||
/* ── Server-generated tool table ── */
|
||||
.table-section-label {
|
||||
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--pg-dimmer);
|
||||
@@ -65,7 +59,7 @@
|
||||
.tool-table tr:hover td { background: rgba(124,58,237,0.04); }
|
||||
.tool-name { font-family: monospace; font-size: 0.82rem; }
|
||||
|
||||
/* Risk badges */
|
||||
/* Risk badges (server-generated) */
|
||||
.risk { display: inline-block; font-size: 0.7rem; font-weight: 700;
|
||||
padding: 0.15rem 0.45rem; border-radius: 9999px; letter-spacing: 0.04em; }
|
||||
.risk-low { background: rgba(34,197,94,0.12); color: #4ade80; }
|
||||
@@ -75,7 +69,7 @@
|
||||
[data-theme="light"] .risk-medium { background: rgba(234,179,8,0.15); color: #ca8a04; }
|
||||
[data-theme="light"] .risk-high { background: rgba(239,68,68,0.15); color: #dc2626; }
|
||||
|
||||
/* Auto status pill */
|
||||
/* Auto-status pill (server-generated, updated by JS) */
|
||||
.auto-pill {
|
||||
display: inline-block; font-size: 0.68rem; font-weight: 600;
|
||||
padding: 0.12rem 0.4rem; border-radius: 9999px;
|
||||
@@ -84,19 +78,13 @@
|
||||
.auto-off { background: rgba(148,163,184,0.12); color: var(--pg-dimmer); }
|
||||
[data-theme="light"] .auto-on { color: #7c3aed; }
|
||||
|
||||
/* Override select */
|
||||
/* Override select (server-generated) */
|
||||
.override-sel {
|
||||
font-size: 0.78rem; padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.3rem; min-width: 7rem; width: auto;
|
||||
}
|
||||
.override-sel.forced-on { border-color: #7c3aed; color: #7c3aed; }
|
||||
.override-sel.forced-off { border-color: #dc2626; color: #dc2626; }
|
||||
|
||||
/* Legend */
|
||||
.legend { display: flex; gap: 1.25rem; flex-wrap: wrap; margin-bottom: 1.25rem; font-size: 0.8rem; color: var(--pg-muted); }
|
||||
.legend-dot { display: inline-block; width: 0.55rem; height: 0.55rem; border-radius: 50%; margin-right: 0.3rem; }
|
||||
.legend-dot.on { background: #a78bfa; }
|
||||
.legend-dot.off { background: var(--pg-dimmer); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -105,8 +93,11 @@
|
||||
<a href="{{ back_href }}" class="nav-link">← Chat</a>
|
||||
<a href="{{ help_href }}" class="nav-link">Help</a>
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<a href="/settings/models" class="nav-link">Models</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||
<a href="/settings/tools" class="nav-link active">Tools</a>
|
||||
<a href="/settings/crons" class="nav-link">Schedules</a>
|
||||
{{ integrations_nav }}
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
@@ -123,55 +114,55 @@
|
||||
|
||||
<form method="POST" action="/settings/tools" id="tools-form">
|
||||
|
||||
<!-- Risk policy -->
|
||||
<div class="policy-card">
|
||||
<h2>Risk Policy</h2>
|
||||
<div class="policy-row">
|
||||
<span class="policy-label">Max risk level</span>
|
||||
<select name="max_risk" id="max-risk-sel">
|
||||
<!-- Risk policy card -->
|
||||
<div class="rounded-xl border border-pg-border bg-pg-surface p-5 mb-5">
|
||||
<h2 class="text-sm font-semibold text-pg-bright mb-4">Risk Policy</h2>
|
||||
<div class="flex items-center gap-4 flex-wrap mb-3">
|
||||
<span class="text-sm font-medium text-pg-text min-w-[6rem]">Max risk level</span>
|
||||
<select name="max_risk" id="max-risk-sel" class="w-auto">
|
||||
<option value="" {{ sel_none }}>No filter — use all role-permitted tools</option>
|
||||
<option value="low" {{ sel_low }}>Low — read-only and sandboxed tools only</option>
|
||||
<option value="medium" {{ sel_medium }}>Medium — low + medium risk (recommended)</option>
|
||||
<option value="high" {{ sel_high }}>High — all tools including destructive ones</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="policy-note">
|
||||
<strong>Low</strong> tools are read-only and sandboxed (web search, project file reads, HA status checks).<br>
|
||||
<strong>Medium</strong> tools write to local data or send notifications to you (cron jobs, scratch, task management).<br>
|
||||
<strong>High</strong> tools affect external systems or the host (shell exec, email, device control, service restart).
|
||||
<p class="text-xs text-pg-muted leading-relaxed mb-2">
|
||||
<strong class="text-pg-text">Low</strong> tools are read-only and sandboxed (web search, project file reads, HA status checks).<br>
|
||||
<strong class="text-pg-text">Medium</strong> tools write to local data or send notifications to you (cron jobs, scratch, task management).<br>
|
||||
<strong class="text-pg-text">High</strong> tools affect external systems or the host (shell exec, email, device control, service restart).
|
||||
</p>
|
||||
<p class="policy-note" style="margin-top:0.75rem;">
|
||||
<p class="text-xs text-pg-muted leading-relaxed">
|
||||
The <em>Auto</em> column below shows each tool's status at your current max risk level.
|
||||
Use the override column to force-include or force-exclude individual tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="legend">
|
||||
<span><span class="legend-dot on"></span>Auto-included by risk level</span>
|
||||
<span><span class="legend-dot off"></span>Auto-excluded by risk level</span>
|
||||
<div class="flex gap-5 flex-wrap mb-4 text-xs text-pg-muted">
|
||||
<span><span class="inline-block w-2 h-2 rounded-full bg-[#a78bfa] mr-1.5"></span>Auto-included by risk level</span>
|
||||
<span><span class="inline-block w-2 h-2 rounded-full bg-pg-dimmer mr-1.5"></span>Auto-excluded by risk level</span>
|
||||
</div>
|
||||
|
||||
<!-- Tool table -->
|
||||
<!-- Tool table (server-generated) -->
|
||||
{{ tool_table_html }}
|
||||
|
||||
<!-- Confirmation gate -->
|
||||
<div class="policy-card" style="margin-top:1.75rem;">
|
||||
<h2>Confirmation Gate</h2>
|
||||
<p class="policy-note">
|
||||
<!-- Confirmation gate card -->
|
||||
<div class="rounded-xl border border-pg-border bg-pg-surface p-5 mt-5 mb-5">
|
||||
<h2 class="text-sm font-semibold text-pg-bright mb-2">Confirmation Gate</h2>
|
||||
<p class="text-xs text-pg-muted leading-relaxed mb-4">
|
||||
Some tools require explicit confirmation before executing. Override the defaults here.<br>
|
||||
Tools requiring confirmation by default: <code>{{ confirm_required_tools }}</code>
|
||||
Tools requiring confirmation by default: <code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1">{{ confirm_required_tools }}</code>
|
||||
</p>
|
||||
<div class="col-split" style="margin-top:0.85rem;">
|
||||
<div class="col-half">
|
||||
<label>Allow list — bypass confirmation</label>
|
||||
<div class="flex gap-6 flex-wrap items-start">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold text-pg-muted mb-1">Allow list — bypass confirmation</label>
|
||||
<textarea name="allow_list" rows="4"
|
||||
placeholder="reminders_clear cron_remove"
|
||||
autocomplete="off" spellcheck="false">{{ tool_allow }}</textarea>
|
||||
<p class="hint">One tool name per line. These tools skip the confirmation prompt.</p>
|
||||
</div>
|
||||
<div class="col-half">
|
||||
<label>Deny list — always block</label>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-semibold text-pg-muted mb-1">Deny list — always block</label>
|
||||
<textarea name="deny_list" rows="4"
|
||||
placeholder="shell_exec file_write"
|
||||
autocomplete="off" spellcheck="false">{{ tool_deny }}</textarea>
|
||||
@@ -180,8 +171,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.5rem;">
|
||||
<button type="submit" class="save-btn">Save tool settings</button>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save tool settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
876
cortex/tests/test_agent_manager.py
Normal file
876
cortex/tests/test_agent_manager.py
Normal file
@@ -0,0 +1,876 @@
|
||||
"""
|
||||
Tests for agent_manager.py and the spawn_agent / aider_run background paths.
|
||||
|
||||
Run with:
|
||||
cd cortex && .venv/bin/python -m pytest tests/test_agent_manager.py -v
|
||||
|
||||
No browser, no LLM calls, no Cortex service needed. All LLM interactions are mocked.
|
||||
The agent_manager tests need no mocks at all — the module is pure asyncio.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_result(response: str = "Agent done."):
|
||||
"""Build a mock OrchestratorResult returned by openai_orchestrator.run."""
|
||||
r = MagicMock()
|
||||
r.checkpoint = None
|
||||
r.response = response
|
||||
return r
|
||||
|
||||
|
||||
def _mock_spawn_deps(
|
||||
model_type: str = "local_openai",
|
||||
user_role: str = "admin",
|
||||
tool_policy: dict | None = None,
|
||||
role_tools: list | None = None,
|
||||
):
|
||||
"""Return a context-manager stack that patches all spawn_agent external deps."""
|
||||
if tool_policy is None:
|
||||
tool_policy = {"allow": [], "deny": []}
|
||||
model_cfg = {
|
||||
"type": model_type,
|
||||
"api_url": "http://localhost:3000",
|
||||
"model_name": "test-model",
|
||||
"api_key": "x",
|
||||
}
|
||||
role_cfg = {
|
||||
"tools": role_tools,
|
||||
"system_append": "",
|
||||
"inject_datetime": True,
|
||||
"inject_mode": True,
|
||||
}
|
||||
|
||||
class _Stack:
|
||||
def __enter__(self_):
|
||||
self_._patches = [
|
||||
patch("model_registry.get_role_config", return_value=role_cfg),
|
||||
patch("model_registry.get_model_for_role", return_value=model_cfg),
|
||||
patch("model_registry.get_registry", return_value={"hosts": []}),
|
||||
patch("context_loader.load_context", return_value="Test system prompt"),
|
||||
patch("auth_utils.get_user_role", return_value=user_role),
|
||||
patch("auth_utils.get_tool_policy", return_value=tool_policy),
|
||||
patch("persona.get_user", return_value="scott"),
|
||||
]
|
||||
for p in self_._patches:
|
||||
p.start()
|
||||
return self_
|
||||
|
||||
def __exit__(self_, *args):
|
||||
for p in self_._patches:
|
||||
p.stop()
|
||||
|
||||
return _Stack()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture — reset agent_manager state between tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_agent_registry():
|
||||
"""Wipe the in-process agent registry before each test."""
|
||||
import agent_manager
|
||||
agent_manager._agents.clear()
|
||||
yield
|
||||
agent_manager._agents.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agent_manager — core CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentManagerCore:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_creates_record(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(
|
||||
user="scott", role="research", task="Investigate topic X", level=2
|
||||
)
|
||||
assert rec.agent_id in agent_manager._agents
|
||||
assert rec.status == "running"
|
||||
assert rec.level == 2
|
||||
assert rec.role == "research"
|
||||
assert rec.task == "Investigate topic X"
|
||||
assert rec.user == "scott"
|
||||
assert rec.finished is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_truncates_long_task(self):
|
||||
import agent_manager
|
||||
long_task = "x" * 500
|
||||
rec = await agent_manager.register(user="scott", role="chat", task=long_task, level=2)
|
||||
assert len(rec.task) == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_updates_record(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
await agent_manager.finish(rec.agent_id, "All done!", "done")
|
||||
|
||||
updated = agent_manager.get(rec.agent_id)
|
||||
assert updated.status == "done"
|
||||
assert updated.result == "All done!"
|
||||
assert updated.finished is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_truncates_result(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
await agent_manager.finish(rec.agent_id, "y" * 2000)
|
||||
|
||||
updated = agent_manager.get(rec.agent_id)
|
||||
assert len(updated.result) <= agent_manager._RESULT_PREVIEW_CHARS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_failed_status(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
await agent_manager.finish(rec.agent_id, "Boom", "failed")
|
||||
assert agent_manager.get(rec.agent_id).status == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_own_agent(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
msg = await agent_manager.cancel_agent(rec.agent_id, "scott")
|
||||
assert "cancelled" in msg
|
||||
assert agent_manager.get(rec.agent_id).status == "cancelled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_wrong_user_denied(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
msg = await agent_manager.cancel_agent(rec.agent_id, "holly")
|
||||
assert "denied" in msg.lower()
|
||||
assert agent_manager.get(rec.agent_id).status == "running"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_nonexistent_agent(self):
|
||||
import agent_manager
|
||||
msg = await agent_manager.cancel_agent("does-not-exist", "scott")
|
||||
assert "No agent found" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_already_done(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
await agent_manager.finish(rec.agent_id, "done", "done")
|
||||
msg = await agent_manager.cancel_agent(rec.agent_id, "scott")
|
||||
assert "already" in msg or "done" in msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_kills_real_task(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
|
||||
sleep_task = asyncio.create_task(asyncio.sleep(60))
|
||||
agent_manager.set_task_ref(rec.agent_id, sleep_task)
|
||||
|
||||
await agent_manager.cancel_agent(rec.agent_id, "scott")
|
||||
await asyncio.sleep(0) # let the event loop process the cancellation
|
||||
|
||||
assert sleep_task.cancelled() or sleep_task.done()
|
||||
|
||||
def test_list_agents_returns_users_agents(self):
|
||||
import agent_manager
|
||||
# Manually populate the registry
|
||||
agent_manager._agents["a1"] = _make_record("a1", "scott", "running")
|
||||
agent_manager._agents["a2"] = _make_record("a2", "scott", "done")
|
||||
agent_manager._agents["a3"] = _make_record("a3", "holly", "running")
|
||||
|
||||
records = agent_manager.list_agents("scott")
|
||||
ids = {r.agent_id for r in records}
|
||||
assert "a1" in ids
|
||||
assert "a2" in ids
|
||||
assert "a3" not in ids
|
||||
|
||||
def test_list_agents_filters_by_status(self):
|
||||
import agent_manager
|
||||
agent_manager._agents["a1"] = _make_record("a1", "scott", "running")
|
||||
agent_manager._agents["a2"] = _make_record("a2", "scott", "done")
|
||||
|
||||
running = agent_manager.list_agents("scott", status="running")
|
||||
assert len(running) == 1
|
||||
assert running[0].agent_id == "a1"
|
||||
|
||||
def test_list_agents_respects_limit(self):
|
||||
import agent_manager
|
||||
for i in range(20):
|
||||
agent_manager._agents[f"a{i}"] = _make_record(f"a{i}", "scott", "done")
|
||||
|
||||
records = agent_manager.list_agents("scott", limit=5)
|
||||
assert len(records) == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prune_removes_old_completed(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
await agent_manager.finish(rec.agent_id, "done")
|
||||
|
||||
# Manually backdate the finished time past the prune threshold
|
||||
agent_manager._agents[rec.agent_id].finished = (
|
||||
datetime.now() - agent_manager._PRUNE_AFTER - timedelta(seconds=1)
|
||||
)
|
||||
|
||||
# Trigger pruning via a new registration
|
||||
await agent_manager.register(user="scott", role="chat", task="t2", level=2)
|
||||
|
||||
assert agent_manager.get(rec.agent_id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prune_keeps_running_agents(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
# Running agent — finished is None so it should never be pruned
|
||||
assert rec.agent_id in agent_manager._agents
|
||||
|
||||
await agent_manager.register(user="scott", role="chat", task="t2", level=2)
|
||||
assert agent_manager.get(rec.agent_id) is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_unknown_agent_is_noop(self):
|
||||
import agent_manager
|
||||
# Should not raise
|
||||
await agent_manager.finish("ghost-id", "result", "done")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# agent_manager — notification hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentManagerNotify:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_called_on_done(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(
|
||||
user="scott", role="chat", task="t", level=2, notify=True
|
||||
)
|
||||
with patch("notification.notify", new_callable=AsyncMock) as mock_notify:
|
||||
await agent_manager.finish(rec.agent_id, "All good", "done")
|
||||
mock_notify.assert_called_once()
|
||||
call_args = mock_notify.call_args
|
||||
assert call_args[0][0] == "scott" # user
|
||||
assert "✅" in call_args[0][1] # success emoji
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_called_on_failed(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(
|
||||
user="scott", role="chat", task="t", level=2, notify=True
|
||||
)
|
||||
with patch("notification.notify", new_callable=AsyncMock) as mock_notify:
|
||||
await agent_manager.finish(rec.agent_id, "Oops", "failed")
|
||||
mock_notify.assert_called_once()
|
||||
assert "⚠️" in mock_notify.call_args[0][1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_notify_when_cancelled(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(
|
||||
user="scott", role="chat", task="t", level=2, notify=True
|
||||
)
|
||||
with patch("notification.notify", new_callable=AsyncMock) as mock_notify:
|
||||
await agent_manager.finish(rec.agent_id, "Cancelled.", "cancelled")
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_notify_when_flag_false(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(
|
||||
user="scott", role="chat", task="t", level=2, notify=False
|
||||
)
|
||||
with patch("notification.notify", new_callable=AsyncMock) as mock_notify:
|
||||
await agent_manager.finish(rec.agent_id, "Done", "done")
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn_agent — background mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpawnAgentBackground:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_returns_agent_id_immediately(self):
|
||||
import agent_manager
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
mock_result = _make_mock_result("Research complete.")
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", new_callable=AsyncMock, return_value=mock_result):
|
||||
result = await spawn_agent(
|
||||
task="Test background research",
|
||||
role="research",
|
||||
background=True,
|
||||
)
|
||||
|
||||
assert "Agent started in background" in result
|
||||
assert "ID:" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_registers_agent(self):
|
||||
import agent_manager
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
mock_result = _make_mock_result()
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", new_callable=AsyncMock, return_value=mock_result):
|
||||
await spawn_agent(task="Background task", background=True)
|
||||
|
||||
agents = agent_manager.list_agents("scott")
|
||||
assert len(agents) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_agent_eventually_completes(self):
|
||||
import agent_manager
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
mock_result = _make_mock_result("Task done!")
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", new_callable=AsyncMock, return_value=mock_result):
|
||||
result = await spawn_agent(task="Quick task", background=True)
|
||||
agent_id = result.split("ID: ")[1].split("\n")[0].strip()
|
||||
|
||||
# Poll while patches are still active
|
||||
for _ in range(40):
|
||||
rec = agent_manager.get(agent_id)
|
||||
if rec and rec.status != "running":
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
rec = agent_manager.get(agent_id)
|
||||
assert rec is not None
|
||||
assert rec.status == "done"
|
||||
assert "Task done!" in (rec.result or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_sync_path_unchanged(self):
|
||||
"""Verify that background=False still blocks and returns the result string."""
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
mock_result = _make_mock_result("Sync result here.")
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", new_callable=AsyncMock, return_value=mock_result):
|
||||
result = await spawn_agent(task="Sync task", background=False)
|
||||
|
||||
assert result == "Sync result here."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_agent_timeout(self):
|
||||
import agent_manager
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
async def _slow(*args, **kwargs):
|
||||
await asyncio.sleep(60)
|
||||
return _make_mock_result()
|
||||
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", side_effect=_slow):
|
||||
result = await spawn_agent(task="Slow task", background=True, timeout=1)
|
||||
agent_id = result.split("ID: ")[1].split("\n")[0].strip()
|
||||
|
||||
# Poll while patches are still active (timeout=1s so this completes quickly)
|
||||
for _ in range(60):
|
||||
rec = agent_manager.get(agent_id)
|
||||
if rec and rec.status != "running":
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
rec = agent_manager.get(agent_id)
|
||||
assert rec.status == "timeout"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_agent_failure(self):
|
||||
import agent_manager
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", new_callable=AsyncMock, side_effect=RuntimeError("Boom")):
|
||||
result = await spawn_agent(task="Failing task", background=True)
|
||||
|
||||
agent_id = result.split("ID: ")[1].split("\n")[0].strip()
|
||||
|
||||
for _ in range(20):
|
||||
rec = agent_manager.get(agent_id)
|
||||
if rec and rec.status != "running":
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert agent_manager.get(agent_id).status == "failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn_agent — level enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLevelEnforcement:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_l2_parent_denies_spawn_in_l3_child(self):
|
||||
"""Level 2 agent spawning a child: spawn_agent and aider_run must be denied."""
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
captured_kwargs = {}
|
||||
|
||||
async def _capture_run(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return _make_mock_result()
|
||||
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", side_effect=_capture_run):
|
||||
await spawn_agent(
|
||||
task="Test L3 enforcement",
|
||||
background=False,
|
||||
_agent_level=2, # this agent is Level 2; its child would be Level 3
|
||||
)
|
||||
|
||||
# The orchestrator should have received spawn_agent and aider_run in confirm_deny
|
||||
confirm_deny = captured_kwargs.get("confirm_deny", set())
|
||||
assert "spawn_agent" in confirm_deny, "spawn_agent must be blocked for L3 children"
|
||||
assert "aider_run" in confirm_deny, "aider_run must be blocked for L3 children"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_l1_parent_does_not_deny_spawn(self):
|
||||
"""Level 1 agent (persona) spawning a Level 2 child: no extra denies."""
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
captured_kwargs = {}
|
||||
|
||||
async def _capture_run(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return _make_mock_result()
|
||||
|
||||
with _mock_spawn_deps():
|
||||
with patch("openai_orchestrator.run", side_effect=_capture_run):
|
||||
await spawn_agent(
|
||||
task="Test L2 spawn",
|
||||
background=False,
|
||||
_agent_level=1, # persona is Level 1; child would be Level 2
|
||||
)
|
||||
|
||||
confirm_deny = captured_kwargs.get("confirm_deny", set())
|
||||
assert "spawn_agent" not in confirm_deny, "L2 agents must be allowed to spawn"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_l2_deny_intersected_with_tool_list(self):
|
||||
"""When the role has an explicit tool_list, L3 deny removes from list directly."""
|
||||
from tools.agents import spawn_agent
|
||||
|
||||
captured_kwargs = {}
|
||||
|
||||
async def _capture_run(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return _make_mock_result()
|
||||
|
||||
# Role has an explicit tool_list that includes spawn_agent
|
||||
with _mock_spawn_deps(role_tools=["web_search", "spawn_agent", "aider_run"]):
|
||||
with patch("openai_orchestrator.run", side_effect=_capture_run):
|
||||
await spawn_agent(
|
||||
task="Test",
|
||||
background=False,
|
||||
_agent_level=2,
|
||||
)
|
||||
|
||||
# spawn_agent and aider_run must be absent from the tool_list passed to orchestrator
|
||||
tool_list = captured_kwargs.get("tool_list", [])
|
||||
assert "spawn_agent" not in tool_list
|
||||
assert "aider_run" not in tool_list
|
||||
assert "web_search" in tool_list # unrelated tools must survive
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent lifecycle tools — output formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentLifecycleTools:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_status_running(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="research", task="Do research", level=2)
|
||||
|
||||
with patch("persona.get_user", return_value="scott"):
|
||||
from tools.agents import agent_status
|
||||
output = await agent_status(rec.agent_id)
|
||||
|
||||
assert "running" in output
|
||||
assert "research" in output
|
||||
assert rec.agent_id[:8] in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_status_done(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="Task", level=2)
|
||||
await agent_manager.finish(rec.agent_id, "The result text", "done")
|
||||
|
||||
with patch("persona.get_user", return_value="scott"):
|
||||
from tools.agents import agent_status
|
||||
output = await agent_status(rec.agent_id)
|
||||
|
||||
assert "done" in output
|
||||
assert "The result text" in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_status_wrong_user(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
|
||||
with patch("persona.get_user", return_value="holly"):
|
||||
from tools.agents import agent_status
|
||||
output = await agent_status(rec.agent_id)
|
||||
|
||||
assert "denied" in output.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_status_not_found(self):
|
||||
with patch("persona.get_user", return_value="scott"):
|
||||
from tools.agents import agent_status
|
||||
output = await agent_status("nonexistent-id")
|
||||
|
||||
assert "No agent found" in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_list_shows_running(self):
|
||||
import agent_manager
|
||||
await agent_manager.register(user="scott", role="research", task="Research X", level=2)
|
||||
await agent_manager.register(user="scott", role="coder", task="Fix bug", level=2)
|
||||
|
||||
with patch("persona.get_user", return_value="scott"):
|
||||
from tools.agents import agent_list
|
||||
output = await agent_list()
|
||||
|
||||
assert "2 agent(s)" in output
|
||||
assert "research" in output
|
||||
assert "coder" in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_list_status_filter(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
await agent_manager.finish(rec.agent_id, "done", "done")
|
||||
await agent_manager.register(user="scott", role="chat", task="t2", level=2)
|
||||
|
||||
with patch("persona.get_user", return_value="scott"):
|
||||
from tools.agents import agent_list
|
||||
output = await agent_list(status="running")
|
||||
|
||||
assert "1 agent(s)" in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_list_empty(self):
|
||||
with patch("persona.get_user", return_value="scott"):
|
||||
from tools.agents import agent_list
|
||||
output = await agent_list()
|
||||
|
||||
assert "No agents found" in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_cancel_tool(self):
|
||||
import agent_manager
|
||||
rec = await agent_manager.register(user="scott", role="chat", task="t", level=2)
|
||||
|
||||
with patch("persona.get_user", return_value="scott"):
|
||||
from tools.agents import agent_cancel
|
||||
output = await agent_cancel(rec.agent_id)
|
||||
|
||||
assert "cancelled" in output
|
||||
assert agent_manager.get(rec.agent_id).status == "cancelled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aider_run — background mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAiderRunBackground:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_returns_agent_id(self):
|
||||
import agent_manager
|
||||
|
||||
async def _fake_proc(*args, **kwargs):
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate = AsyncMock(return_value=(b"All changes applied.", b""))
|
||||
mock_proc.returncode = 0
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch("persona.get_user", return_value="scott"),
|
||||
patch("model_registry.get_registry", return_value={"hosts": []}),
|
||||
patch("asyncio.create_subprocess_exec", side_effect=_fake_proc),
|
||||
):
|
||||
from tools.aider import aider_run
|
||||
result = await aider_run(
|
||||
project=str(_CORTEX_DIR.parent), # use actual project root (exists)
|
||||
task="Test background task",
|
||||
background=True,
|
||||
)
|
||||
|
||||
assert "Aider task started in background" in result
|
||||
assert "ID:" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_agent_completes(self):
|
||||
import agent_manager
|
||||
|
||||
async def _fake_proc(*args, **kwargs):
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate = AsyncMock(return_value=(b"Edits applied.", b""))
|
||||
mock_proc.returncode = 0
|
||||
return mock_proc
|
||||
|
||||
from tools.aider import aider_run
|
||||
with (
|
||||
patch("persona.get_user", return_value="scott"),
|
||||
patch("model_registry.get_registry", return_value={"hosts": []}),
|
||||
patch("asyncio.create_subprocess_exec", side_effect=_fake_proc),
|
||||
):
|
||||
result = await aider_run(
|
||||
project=str(_CORTEX_DIR.parent),
|
||||
task="Test",
|
||||
background=True,
|
||||
)
|
||||
agent_id = result.split("ID: ")[1].split("\n")[0].strip()
|
||||
|
||||
# Poll while patches are still active
|
||||
for _ in range(40):
|
||||
rec = agent_manager.get(agent_id)
|
||||
if rec and rec.status != "running":
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
rec = agent_manager.get(agent_id)
|
||||
assert rec.status == "done"
|
||||
assert "Edits applied" in (rec.result or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_project_directory(self):
|
||||
from tools.aider import aider_run
|
||||
result = await aider_run(project="/this/does/not/exist", task="Test")
|
||||
assert "does not exist" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_path_still_works(self):
|
||||
async def _fake_proc(*args, **kwargs):
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate = AsyncMock(return_value=(b"Done.", b""))
|
||||
mock_proc.returncode = 0
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch("persona.get_user", return_value="scott"),
|
||||
patch("model_registry.get_registry", return_value={"hosts": []}),
|
||||
patch("asyncio.create_subprocess_exec", side_effect=_fake_proc),
|
||||
):
|
||||
from tools.aider import aider_run
|
||||
result = await aider_run(
|
||||
project=str(_CORTEX_DIR.parent),
|
||||
task="Sync test",
|
||||
background=False,
|
||||
)
|
||||
|
||||
assert "Done." in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aider_run — credential resolver (_resolve_credentials)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAiderCredentialResolver:
|
||||
"""Pure unit tests for _resolve_credentials — no subprocess, no registry I/O."""
|
||||
|
||||
def _registry(self, hosts=None, anthropic_key=None):
|
||||
reg = {"hosts": hosts or [], "providers": {}}
|
||||
if anthropic_key:
|
||||
reg["providers"]["anthropic"] = {
|
||||
"credentials": [{"api_key": anthropic_key}]
|
||||
}
|
||||
return reg
|
||||
|
||||
def _host(self, label, api_url, api_key="sk-test", host_type="openai"):
|
||||
return {"id": "x", "label": label, "api_url": api_url,
|
||||
"api_key": api_key, "host_type": host_type}
|
||||
|
||||
# --- Provider detection ---
|
||||
|
||||
def test_openrouter_host_gets_api_key_flag(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"),
|
||||
])
|
||||
flags, model = _resolve_credentials(reg, None, None)
|
||||
assert "--api-key" in flags
|
||||
assert "openrouter=or-key" in flags
|
||||
|
||||
def test_anthropic_model_hint_uses_provider_key(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(
|
||||
hosts=[self._host("OpenRouter", "https://openrouter.ai/api/v1")],
|
||||
anthropic_key="ant-key",
|
||||
)
|
||||
flags, model = _resolve_credentials(reg, "claude-3-5-sonnet-20241022", None)
|
||||
assert "anthropic=ant-key" in flags
|
||||
assert model == "claude-3-5-sonnet-20241022"
|
||||
|
||||
def test_anthropic_slash_prefix_hint(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(anthropic_key="ant-key")
|
||||
flags, _ = _resolve_credentials(reg, "anthropic/claude-opus-4", None)
|
||||
assert "anthropic=ant-key" in flags
|
||||
|
||||
def test_local_openwebui_host_gets_base_url(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("Local", "http://192.168.32.19:3000", "localkey", host_type="openwebui"),
|
||||
])
|
||||
flags, model = _resolve_credentials(reg, None, None)
|
||||
assert "--openai-api-base" in flags
|
||||
base = flags[flags.index("--openai-api-base") + 1]
|
||||
assert base == "http://192.168.32.19:3000/api"
|
||||
assert "--openai-api-key" in flags
|
||||
|
||||
def test_local_host_appends_api_suffix_for_openwebui(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("OpenWebUI", "http://localhost:3000", host_type="openwebui"),
|
||||
])
|
||||
flags, _ = _resolve_credentials(reg, None, None)
|
||||
base = flags[flags.index("--openai-api-base") + 1]
|
||||
assert base.endswith("/api")
|
||||
|
||||
def test_generic_openai_host_no_api_suffix(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("Custom", "http://localhost:8080/v1", host_type="openai"),
|
||||
])
|
||||
flags, _ = _resolve_credentials(reg, None, None)
|
||||
base = flags[flags.index("--openai-api-base") + 1]
|
||||
assert not base.endswith("/api")
|
||||
assert base == "http://localhost:8080/v1"
|
||||
|
||||
# --- Model name adjustment ---
|
||||
|
||||
def test_local_host_prefixes_model_without_slash(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("Local", "http://localhost:3000", host_type="openwebui"),
|
||||
])
|
||||
_, model = _resolve_credentials(reg, "gemma-4-27b-it", None)
|
||||
assert model == "openai/gemma-4-27b-it"
|
||||
|
||||
def test_local_host_leaves_model_with_slash(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("Local", "http://localhost:3000", host_type="openwebui"),
|
||||
])
|
||||
_, model = _resolve_credentials(reg, "ollama/gemma4", None)
|
||||
assert model == "ollama/gemma4" # already prefixed, don't touch
|
||||
|
||||
def test_cloud_provider_does_not_prefix_model(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("OpenRouter", "https://openrouter.ai/api/v1"),
|
||||
])
|
||||
_, model = _resolve_credentials(reg, "google/gemma-3-27b-it", None)
|
||||
assert model == "google/gemma-3-27b-it"
|
||||
|
||||
# --- Host label override ---
|
||||
|
||||
def test_host_label_selects_local_over_openrouter(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"),
|
||||
self._host("Local RTX", "http://192.168.32.19:3000", "local-key", host_type="openwebui"),
|
||||
])
|
||||
flags, _ = _resolve_credentials(reg, None, "Local")
|
||||
assert "--openai-api-base" in flags
|
||||
assert "--api-key" not in flags
|
||||
|
||||
def test_host_label_case_insensitive(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"),
|
||||
])
|
||||
flags, _ = _resolve_credentials(reg, None, "openrouter")
|
||||
assert "openrouter=or-key" in flags
|
||||
|
||||
# --- Model prefix routing ---
|
||||
|
||||
def test_model_openrouter_prefix_routes_to_openrouter(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("Local", "http://localhost:3000", host_type="openwebui"),
|
||||
self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"),
|
||||
])
|
||||
flags, model = _resolve_credentials(reg, "openrouter/google/gemma-3-27b-it", None)
|
||||
assert "openrouter=or-key" in flags
|
||||
assert model == "openrouter/google/gemma-3-27b-it"
|
||||
|
||||
def test_model_groq_prefix_routes_to_groq_host(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("Groq", "https://api.groq.com/openai/v1", "groq-key"),
|
||||
])
|
||||
flags, _ = _resolve_credentials(reg, "groq/llama-3.3-70b", None)
|
||||
assert "groq=groq-key" in flags
|
||||
|
||||
# --- Default fallback priority ---
|
||||
|
||||
def test_prefers_openrouter_over_local_when_no_hint(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(hosts=[
|
||||
self._host("Local", "http://localhost:3000", host_type="openwebui"),
|
||||
self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"),
|
||||
])
|
||||
flags, _ = _resolve_credentials(reg, None, None)
|
||||
assert "openrouter=or-key" in flags
|
||||
|
||||
def test_prefers_anthropic_over_local_when_no_openrouter(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
reg = self._registry(
|
||||
hosts=[self._host("Local", "http://localhost:3000", host_type="openwebui")],
|
||||
anthropic_key="ant-key",
|
||||
)
|
||||
flags, _ = _resolve_credentials(reg, None, None)
|
||||
assert "anthropic=ant-key" in flags
|
||||
|
||||
def test_empty_registry_returns_no_flags(self):
|
||||
from tools.aider import _resolve_credentials
|
||||
flags, model = _resolve_credentials({}, "gemma-4", None)
|
||||
assert flags == []
|
||||
assert model == "gemma-4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for manual test record creation (used in list tests above)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import agent_manager as _am
|
||||
|
||||
_CORTEX_DIR = _am.__file__ and _am and __import__("pathlib").Path(_am.__file__).parent
|
||||
|
||||
|
||||
def _make_record(agent_id: str, user: str, status: str) -> "_am.AgentRecord":
|
||||
from datetime import datetime
|
||||
import agent_manager
|
||||
rec = agent_manager.AgentRecord(
|
||||
agent_id=agent_id,
|
||||
level=2,
|
||||
role="chat",
|
||||
task="test task",
|
||||
status=status,
|
||||
started=datetime.now(),
|
||||
user=user,
|
||||
finished=datetime.now() if status != "running" else None,
|
||||
)
|
||||
return rec
|
||||
@@ -25,7 +25,10 @@ async def test_files_get_allowed(client):
|
||||
@pytest.mark.anyio
|
||||
async def test_files_get_not_in_allowed(client):
|
||||
"""Files outside the ALLOWED set should return 404, not the file content."""
|
||||
for name in ("TASKS.json", "CRONS.json", "SCRATCH.md", "../config.py", ".env"):
|
||||
# Note: paths with '..' are normalized at the ASGI layer (e.g. /files/../config.py
|
||||
# becomes /config.py which hits the /{username} UI catch-all, not the files router).
|
||||
# Only test paths that stay within the files router's scope.
|
||||
for name in ("TASKS.json", "CRONS.json", "SCRATCH.md", ".env"):
|
||||
r = await client.get(f"/files/{name}")
|
||||
assert r.status_code == 404, f"Expected 404 for {name}, got {r.status_code}"
|
||||
|
||||
|
||||
@@ -30,5 +30,7 @@ async def test_distill_status(client):
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unknown_route_404(client):
|
||||
r = await client.get("/does-not-exist")
|
||||
# Single-segment paths hit the /{username} persona-picker catch-all (302 redirect).
|
||||
# Three-segment paths don't match any route pattern → genuine 404.
|
||||
r = await client.get("/totally/unknown/deep-path")
|
||||
assert r.status_code == 404
|
||||
|
||||
@@ -69,10 +69,11 @@ async def test_nct_replayed_request_rejected(client):
|
||||
payload = json.dumps({"type": "Create", "actor": {}, "object": {}, "target": {}}).encode()
|
||||
# Use wrong secret to generate sig
|
||||
wrong_sig = hmac_lib.new(b"wrong-secret", b"abc123" + payload, hashlib.sha256).hexdigest()
|
||||
_channels = {"nextcloud": {"bot_secret": "correct-secret", "url": "https://nc.example.com"}}
|
||||
from unittest.mock import patch
|
||||
with patch("config.settings.nextcloud_talk_bot_secret", "correct-secret"):
|
||||
with patch("routers.nextcloud_talk.get_user_channels", return_value=_channels):
|
||||
r = await client.post(
|
||||
"/inara-nextcloud-talk-webhook",
|
||||
"/webhook/nextcloud/scott",
|
||||
content=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -118,9 +119,11 @@ async def test_known_gap__gchat_no_audience_bypass(client, mock_llm):
|
||||
LLM responses without a valid token.
|
||||
Fix: make audience required; fail loudly if not set.
|
||||
"""
|
||||
# Channel config with no audience — JWT check is skipped (the known gap).
|
||||
_channels = {"google_chat": {"persona": "inara"}}
|
||||
from unittest.mock import patch
|
||||
with patch("config.settings.google_chat_audience", ""):
|
||||
r = await client.post("/channels/google-chat", json={
|
||||
with patch("routers.google_chat.get_user_channels", return_value=_channels):
|
||||
r = await client.post("/channels/google-chat/scott", json={
|
||||
"chat": {
|
||||
"messagePayload": {
|
||||
"message": {"text": "Exploit"},
|
||||
|
||||
@@ -101,19 +101,19 @@ class TestTasks:
|
||||
|
||||
def test_list_empty(self):
|
||||
from tools.tasks import _task_list
|
||||
assert "No tasks" in _task_list(status=None)
|
||||
assert "No tasks" in _task_list(status=None, priority=None)
|
||||
|
||||
def test_create_and_list(self):
|
||||
from tools.tasks import _task_list
|
||||
self._mk("Buy coffee", description="Dark roast", priority="high")
|
||||
result = _task_list(status=None)
|
||||
result = _task_list(status=None, priority=None)
|
||||
assert "Buy coffee" in result
|
||||
assert "[high]" in result
|
||||
|
||||
def test_create_bad_priority_defaults_to_normal(self):
|
||||
from tools.tasks import _task_list
|
||||
self._mk("Test task", priority="urgent") # invalid — becomes "normal"
|
||||
result = _task_list(status=None)
|
||||
result = _task_list(status=None, priority=None)
|
||||
assert "Test task" in result
|
||||
assert "[normal]" not in result # normal priority not shown in brackets
|
||||
|
||||
@@ -121,20 +121,20 @@ class TestTasks:
|
||||
from tools.tasks import _task_update, _task_list
|
||||
tid = self._id(self._mk("Work item"))
|
||||
_task_update(tid, status="in_progress", title=None, description=None, priority=None)
|
||||
assert "Work item" in _task_list(status="in_progress")
|
||||
assert "Work item" in _task_list(status="in_progress", priority=None)
|
||||
|
||||
def test_complete(self):
|
||||
from tools.tasks import _task_complete, _task_list
|
||||
tid = self._id(self._mk("Finish this"))
|
||||
_task_complete(tid)
|
||||
assert "Finish this" in _task_list(status="done")
|
||||
assert "Finish this" not in _task_list(status="todo")
|
||||
assert "Finish this" in _task_list(status="done", priority=None)
|
||||
assert "Finish this" not in _task_list(status="todo", priority=None)
|
||||
|
||||
def test_filter_by_status(self):
|
||||
from tools.tasks import _task_list
|
||||
self._mk("A task")
|
||||
assert "A task" in _task_list(status="todo")
|
||||
assert "A task" not in _task_list(status="done")
|
||||
assert "A task" in _task_list(status="todo", priority=None)
|
||||
assert "A task" not in _task_list(status="done", priority=None)
|
||||
|
||||
def test_update_unknown_id(self):
|
||||
from tools.tasks import _task_update
|
||||
@@ -231,7 +231,8 @@ class TestCronTools:
|
||||
|
||||
def _extract_id(self, result: str) -> str:
|
||||
import re
|
||||
m = re.search(r'c_\w+', result)
|
||||
# token_urlsafe can include '-'; use [\w-]+ to capture the full ID
|
||||
m = re.search(r'c_[\w-]+', result)
|
||||
assert m, f"No cron ID in: {result}"
|
||||
return m.group()
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
Webhook auth tests — NC Talk HMAC, Google Chat JWT.
|
||||
|
||||
These tests verify that auth is enforced, not that full LLM responses work.
|
||||
|
||||
Architecture note: channel config (secrets, audience) lives in per-user channels.json,
|
||||
not in settings. Tests mock get_user_channels() rather than patching settings fields.
|
||||
Endpoints are per-user: /webhook/nextcloud/{username} and /channels/google-chat/{username}.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -26,6 +30,14 @@ _VALID_NC_PAYLOAD = {
|
||||
"target": {"id": "abc123token"},
|
||||
}
|
||||
|
||||
_NCT_CHANNELS = {
|
||||
"nextcloud": {
|
||||
"bot_secret": _NC_SECRET,
|
||||
"notification_room": "abc123token",
|
||||
"url": "https://nc.example.com",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _nc_headers(body: bytes, secret: str) -> dict:
|
||||
random_str = "abc123"
|
||||
@@ -43,11 +55,11 @@ def _nc_headers(body: bytes, secret: str) -> dict:
|
||||
@pytest.mark.anyio
|
||||
async def test_nct_valid_signature(client, mock_llm):
|
||||
body = json.dumps(_VALID_NC_PAYLOAD).encode()
|
||||
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
|
||||
with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS):
|
||||
with patch("routers.nextcloud_talk._send_reply", new_callable=AsyncMock):
|
||||
headers = _nc_headers(body, _NC_SECRET)
|
||||
r = await client.post(
|
||||
"/inara-nextcloud-talk-webhook",
|
||||
"/webhook/nextcloud/scott",
|
||||
content=body,
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
)
|
||||
@@ -57,9 +69,9 @@ async def test_nct_valid_signature(client, mock_llm):
|
||||
@pytest.mark.anyio
|
||||
async def test_nct_wrong_signature(client):
|
||||
body = json.dumps(_VALID_NC_PAYLOAD).encode()
|
||||
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
|
||||
with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS):
|
||||
r = await client.post(
|
||||
"/inara-nextcloud-talk-webhook",
|
||||
"/webhook/nextcloud/scott",
|
||||
content=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -73,9 +85,9 @@ async def test_nct_wrong_signature(client):
|
||||
@pytest.mark.anyio
|
||||
async def test_nct_missing_signature(client):
|
||||
body = json.dumps(_VALID_NC_PAYLOAD).encode()
|
||||
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
|
||||
with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS):
|
||||
r = await client.post(
|
||||
"/inara-nextcloud-talk-webhook",
|
||||
"/webhook/nextcloud/scott",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
@@ -84,11 +96,13 @@ async def test_nct_missing_signature(client):
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nct_no_secret_configured(client):
|
||||
"""Service should return 500 if secret is not set, not process the message."""
|
||||
"""Service should return 500 if bot_secret is missing, not process the message."""
|
||||
body = json.dumps(_VALID_NC_PAYLOAD).encode()
|
||||
with patch("config.settings.nextcloud_talk_bot_secret", ""):
|
||||
# cfg must be non-empty (truthy) to get past the 404 guard; missing bot_secret → 500
|
||||
empty_cfg = {"nextcloud": {"url": "https://nc.example.com"}}
|
||||
with patch("routers.nextcloud_talk.get_user_channels", return_value=empty_cfg):
|
||||
r = await client.post(
|
||||
"/inara-nextcloud-talk-webhook",
|
||||
"/webhook/nextcloud/scott",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
@@ -100,10 +114,10 @@ async def test_nct_bot_message_ignored(client):
|
||||
"""Messages from other bots should be silently ignored (not processed)."""
|
||||
payload = {**_VALID_NC_PAYLOAD, "actor": {"type": "bots", "id": "otherbot", "name": "Bot"}}
|
||||
body = json.dumps(payload).encode()
|
||||
with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET):
|
||||
with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS):
|
||||
headers = _nc_headers(body, _NC_SECRET)
|
||||
r = await client.post(
|
||||
"/inara-nextcloud-talk-webhook",
|
||||
"/webhook/nextcloud/scott",
|
||||
content=body,
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
)
|
||||
@@ -124,21 +138,29 @@ _GCHAT_PAYLOAD = {
|
||||
}
|
||||
}
|
||||
|
||||
_GCHAT_CHANNELS_NO_AUDIENCE = {
|
||||
# cfg must be non-empty (truthy) to pass the 404 guard; no audience → JWT skipped
|
||||
"google_chat": {"persona": "inara"}
|
||||
}
|
||||
|
||||
_GCHAT_CHANNELS_WITH_AUDIENCE = {
|
||||
"google_chat": {"audience": "123456789"}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gchat_no_audience_configured(client, mock_llm):
|
||||
"""When audience is not set, JWT check is skipped (current behaviour — documented bypass)."""
|
||||
with patch("config.settings.google_chat_audience", ""):
|
||||
r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD)
|
||||
# Should process the message (no auth enforcement when audience is empty)
|
||||
with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_NO_AUDIENCE):
|
||||
r = await client.post("/channels/google-chat/scott", json=_GCHAT_PAYLOAD)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gchat_missing_token_with_audience(client):
|
||||
"""When audience IS configured, requests without a token must be rejected."""
|
||||
with patch("config.settings.google_chat_audience", "123456789"):
|
||||
r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD)
|
||||
with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_WITH_AUDIENCE):
|
||||
r = await client.post("/channels/google-chat/scott", json=_GCHAT_PAYLOAD)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@@ -149,8 +171,8 @@ async def test_gchat_invalid_token_with_audience(client):
|
||||
**_GCHAT_PAYLOAD,
|
||||
"authorizationEventObject": {"systemIdToken": "not.a.valid.jwt"},
|
||||
}
|
||||
with patch("config.settings.google_chat_audience", "123456789"):
|
||||
r = await client.post("/channels/google-chat", json=payload_with_token)
|
||||
with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_WITH_AUDIENCE):
|
||||
r = await client.post("/channels/google-chat/scott", json=payload_with_token)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@@ -158,7 +180,7 @@ async def test_gchat_invalid_token_with_audience(client):
|
||||
async def test_gchat_added_to_space(client, mock_llm):
|
||||
"""Bot added to a space — should return a greeting, no auth when audience empty."""
|
||||
payload = {"chat": {"addedToSpacePayload": {"space": {"type": "ROOM"}}}}
|
||||
with patch("config.settings.google_chat_audience", ""):
|
||||
r = await client.post("/channels/google-chat", json=payload)
|
||||
with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_NO_AUDIENCE):
|
||||
r = await client.post("/channels/google-chat/scott", json=payload)
|
||||
assert r.status_code == 200
|
||||
assert "hostAppDataAction" in r.json()
|
||||
|
||||
@@ -35,6 +35,7 @@ from tools.files import (
|
||||
project_file_list as _project_file_list,
|
||||
file_stat as _file_stat,
|
||||
file_grep as _file_grep,
|
||||
file_diff as _file_diff,
|
||||
file_syntax_check as _file_syntax_check,
|
||||
file_read as _file_read,
|
||||
file_list as _file_list,
|
||||
@@ -81,12 +82,28 @@ from tools.agent_notes import (
|
||||
agent_notes_append as _agent_notes_append,
|
||||
agent_notes_clear as _agent_notes_clear,
|
||||
)
|
||||
from tools.agents import spawn_agent as _spawn_agent
|
||||
from tools.git import (
|
||||
git_status as _git_status,
|
||||
git_log as _git_log,
|
||||
git_diff as _git_diff,
|
||||
)
|
||||
from tools.agents import (
|
||||
spawn_agent as _spawn_agent,
|
||||
agent_status as _agent_status,
|
||||
agent_list as _agent_list,
|
||||
agent_cancel as _agent_cancel,
|
||||
)
|
||||
from tools.aider import aider_run as _aider_run
|
||||
from tools.homeassistant import (
|
||||
ha_get_state as _ha_get_state,
|
||||
ha_get_states as _ha_get_states,
|
||||
ha_call_service as _ha_call_service,
|
||||
)
|
||||
from tools.ae_database import (
|
||||
ae_db_query as _ae_db_query,
|
||||
ae_db_describe as _ae_db_describe,
|
||||
ae_db_show_view as _ae_db_show_view,
|
||||
)
|
||||
|
||||
# ── Declaration imports ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,14 +118,18 @@ import tools.reminders as _mod_reminders
|
||||
import tools.scratch as _mod_scratch
|
||||
import tools.notify as _mod_notify
|
||||
import tools.agent_notes as _mod_agent_notes
|
||||
import tools.git as _mod_git
|
||||
import tools.agents as _mod_agents
|
||||
import tools.aider as _mod_aider
|
||||
import tools.homeassistant as _mod_homeassistant
|
||||
import tools.ae_database as _mod_ae_database
|
||||
|
||||
# ── Tool categories — used by the Model Registry UI for grouped checkboxes ───
|
||||
|
||||
TOOL_CATEGORIES: dict[str, list[str]] = {
|
||||
"Web": ["web_search", "http_fetch", "web_read", "http_post"],
|
||||
"Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_syntax_check"],
|
||||
"Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_diff", "file_syntax_check"],
|
||||
"Git": ["git_status", "git_log", "git_diff"],
|
||||
"System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"],
|
||||
"Shell": ["shell_exec", "claude_allow_dir"],
|
||||
"System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"],
|
||||
@@ -126,8 +147,9 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
|
||||
],
|
||||
"Aether Tasks": ["ae_task_list"],
|
||||
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
|
||||
"Agents": ["spawn_agent"],
|
||||
"Agents": ["spawn_agent", "agent_status", "agent_list", "agent_cancel", "aider_run"],
|
||||
"Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"],
|
||||
"Aether Database": ["ae_db_query", "ae_db_describe", "ae_db_show_view"],
|
||||
}
|
||||
|
||||
# ── Callable registry ─────────────────────────────────────────────────────────
|
||||
@@ -151,6 +173,7 @@ _CALLABLES: dict[str, callable] = {
|
||||
"project_file_list": _project_file_list,
|
||||
"file_stat": _file_stat,
|
||||
"file_grep": _file_grep,
|
||||
"file_diff": _file_diff,
|
||||
"file_syntax_check": _file_syntax_check,
|
||||
"file_read": _file_read,
|
||||
"file_list": _file_list,
|
||||
@@ -187,10 +210,20 @@ _CALLABLES: dict[str, callable] = {
|
||||
"agent_notes_write": _agent_notes_write,
|
||||
"agent_notes_append": _agent_notes_append,
|
||||
"agent_notes_clear": _agent_notes_clear,
|
||||
"git_status": _git_status,
|
||||
"git_log": _git_log,
|
||||
"git_diff": _git_diff,
|
||||
"spawn_agent": _spawn_agent,
|
||||
"agent_status": _agent_status,
|
||||
"agent_list": _agent_list,
|
||||
"agent_cancel": _agent_cancel,
|
||||
"aider_run": _aider_run,
|
||||
"ha_get_state": _ha_get_state,
|
||||
"ha_get_states": _ha_get_states,
|
||||
"ha_call_service": _ha_call_service,
|
||||
"ae_db_query": _ae_db_query,
|
||||
"ae_db_describe": _ae_db_describe,
|
||||
"ae_db_show_view": _ae_db_show_view,
|
||||
}
|
||||
|
||||
# ── Role-based access control ─────────────────────────────────────────────────
|
||||
@@ -208,11 +241,18 @@ TOOL_ROLES: dict[str, str] = {
|
||||
"file_write": "admin",
|
||||
"ae_task_list": "admin",
|
||||
"spawn_agent": "admin",
|
||||
"agent_status": "user",
|
||||
"agent_list": "user",
|
||||
"agent_cancel": "admin",
|
||||
"aider_run": "admin",
|
||||
"email_send": "admin",
|
||||
"nc_talk_send": "admin",
|
||||
"http_post": "admin",
|
||||
"nc_talk_history": "admin",
|
||||
"ha_call_service": "admin",
|
||||
"ae_db_query": "admin",
|
||||
"ae_db_describe": "admin",
|
||||
"ae_db_show_view": "admin",
|
||||
}
|
||||
|
||||
# Tools that require explicit user confirmation before executing.
|
||||
@@ -225,6 +265,9 @@ CONFIRM_REQUIRED: set[str] = {
|
||||
"reminders_clear",
|
||||
"http_post",
|
||||
"ha_call_service",
|
||||
"ae_journal_entry_disable", # disables a journal entry — not easily reversed
|
||||
"agent_cancel", # kills a running background task
|
||||
"aider_run", # edits files and commits — irreversible without git revert
|
||||
}
|
||||
|
||||
# Security risk ratings — informational for now; will drive auto-allow tiers later.
|
||||
@@ -247,6 +290,7 @@ TOOL_RISK: dict[str, str] = {
|
||||
"project_file_list": "low",
|
||||
"file_stat": "low",
|
||||
"file_grep": "low",
|
||||
"file_diff": "low",
|
||||
"file_syntax_check": "low",
|
||||
|
||||
# System Files — reads beyond project scope are medium; writes are high
|
||||
@@ -316,13 +360,27 @@ TOOL_RISK: dict[str, str] = {
|
||||
"agent_notes_append": "low",
|
||||
"agent_notes_clear": "low",
|
||||
|
||||
# Agents — spawning a subprocess with broad permissions is high
|
||||
# Git — all read-only inspections
|
||||
"git_status": "low",
|
||||
"git_log": "low",
|
||||
"git_diff": "low",
|
||||
|
||||
# Agents — spawning is high; lifecycle reads are low; cancel is medium (kills a task)
|
||||
"spawn_agent": "high",
|
||||
"agent_status": "low",
|
||||
"agent_list": "low",
|
||||
"agent_cancel": "medium",
|
||||
"aider_run": "high",
|
||||
|
||||
# Home Assistant — reads are low; controlling physical devices is high
|
||||
"ha_get_state": "low",
|
||||
"ha_get_states": "low",
|
||||
"ha_call_service": "high",
|
||||
|
||||
# Aether Database — all read-only; query reads data, describe/show_view read schema only
|
||||
"ae_db_query": "medium",
|
||||
"ae_db_describe": "low",
|
||||
"ae_db_show_view": "low",
|
||||
}
|
||||
|
||||
_RISK_RANK: dict[str, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
@@ -340,6 +398,7 @@ def _role_allowed(tool_name: str, role: str) -> bool:
|
||||
_ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
||||
_mod_web.DECLARATIONS
|
||||
+ _mod_files.DECLARATIONS
|
||||
+ _mod_git.DECLARATIONS
|
||||
+ _mod_system.DECLARATIONS
|
||||
+ _mod_tasks.DECLARATIONS
|
||||
+ _mod_cron.DECLARATIONS
|
||||
@@ -350,7 +409,9 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
||||
+ _mod_ae_tasks.DECLARATIONS
|
||||
+ _mod_agent_notes.DECLARATIONS
|
||||
+ _mod_agents.DECLARATIONS
|
||||
+ _mod_aider.DECLARATIONS
|
||||
+ _mod_homeassistant.DECLARATIONS
|
||||
+ _mod_ae_database.DECLARATIONS
|
||||
)
|
||||
|
||||
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)
|
||||
@@ -515,3 +576,114 @@ def get_openai_tools_for_role(
|
||||
if tool_list is not None:
|
||||
allowed &= set(tool_list)
|
||||
return [t for t in OPENAI_TOOL_SCHEMAS if t["function"]["name"] in allowed]
|
||||
|
||||
|
||||
# ── Keyword-based tool routing ─────────────────────────────────────────────────
|
||||
|
||||
# Maps classifier category names → tool names in that category
|
||||
CATEGORY_TOOL_MAP: dict[str, list[str]] = {
|
||||
"web": ["web_search", "web_read", "http_fetch"],
|
||||
"web_post": ["http_post"],
|
||||
"file": ["project_file_read", "project_file_list", "file_stat", "file_grep",
|
||||
"file_diff", "file_syntax_check", "file_read", "file_list", "file_write"],
|
||||
"git": ["git_status", "git_log", "git_diff"],
|
||||
"system": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update", "shell_exec"],
|
||||
"tasks": ["task_list", "task_create", "task_update", "task_complete"],
|
||||
"cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
|
||||
"reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
|
||||
"scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"],
|
||||
"ha": ["ha_get_state", "ha_get_states", "ha_call_service"],
|
||||
"aether": ["ae_journal_list", "ae_journal_search", "ae_journal_entries_list",
|
||||
"ae_journal_entry_read", "ae_journal_entry_create", "ae_journal_entry_update",
|
||||
"ae_journal_entry_disable", "ae_journal_entry_append", "ae_journal_entry_prepend"],
|
||||
"aether_db": ["ae_db_query", "ae_db_describe", "ae_db_show_view"],
|
||||
"notifications":["web_push", "email_send", "nc_talk_send", "nc_talk_history"],
|
||||
"agents": ["spawn_agent", "agent_status", "agent_list", "agent_cancel", "aider_run"],
|
||||
"notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
|
||||
"session": ["session_read", "session_search"],
|
||||
"ae_tasks": ["ae_task_list"],
|
||||
"claude": ["claude_allow_dir"],
|
||||
}
|
||||
|
||||
_KEYWORD_CATEGORY_MAP: dict[str, list[str]] = {
|
||||
"web": ["search", "look up", "what is", "who is", "weather", "forecast",
|
||||
"news", "find on", "google", "website", "article", "research",
|
||||
"temperature"],
|
||||
"web_post": ["post to", "send to", "webhook", "trigger webhook"],
|
||||
"file": ["read file", "show file", "list file", "directory", "grep",
|
||||
"search in", "find in", "diff", "compare", "syntax check", "open file"],
|
||||
"git": ["git", "commit", "branch", "pulled", "merged", "repository", "repo"],
|
||||
"system": ["restart", "update", "status", "logs", "log", "deploy", "run command",
|
||||
"shell", "is it running", "health"],
|
||||
"tasks": ["task", "todo", "to-do", "to do", "add task", "create task",
|
||||
"pending", "what's on my list"],
|
||||
"cron": ["schedule", "cron", "every day", "every week", "recurring",
|
||||
"automate", "job"],
|
||||
"reminders": ["remind", "reminder", "don't forget"],
|
||||
"scratchpad": ["scratch", "scratchpad", "working note", "jot down", "notepad"],
|
||||
"ha": ["home assistant", "light", "thermostat", "turn on", "turn off",
|
||||
"switch", "sensor", "temperature in", "kitchen", "bedroom", "garage"],
|
||||
"aether": ["journal", "aether journal", "note entry", "log entry",
|
||||
"search journal", "ae_journal"],
|
||||
"aether_db": ["database", "query", "sql", "select", "db", "table",
|
||||
"schema", "maria", "run query"],
|
||||
"notifications":["notify", "push notification", "send email", "email",
|
||||
"talk message", "nextcloud"],
|
||||
"agents": ["spawn", "sub-agent", "delegate", "spawn agent",
|
||||
"agent status", "agent list", "cancel agent", "background agent",
|
||||
"aider", "code change", "edit code", "make a change to", "fix the code"],
|
||||
"notes": ["agent notes", "private notes", "my notes", "agent_notes"],
|
||||
"session": ["session", "history", "last time", "what did we", "earlier",
|
||||
"yesterday", "last week", "previously"],
|
||||
"ae_tasks": ["ae task", "kanban", "board", "ae_task"],
|
||||
"claude": ["claude allow", "claude directory"],
|
||||
}
|
||||
|
||||
|
||||
def classify_tool_categories(message: str) -> list[str]:
|
||||
"""Return category names whose keywords appear in message (case-insensitive).
|
||||
|
||||
Empty return means no tool category matched — route as pure chat with zero tool overhead.
|
||||
"""
|
||||
low = message.lower()
|
||||
return [cat for cat, kws in _KEYWORD_CATEGORY_MAP.items() if any(kw in low for kw in kws)]
|
||||
|
||||
|
||||
def narrow_tools_by_keywords(
|
||||
message: str,
|
||||
role_tools: list[str] | None,
|
||||
context_messages: list[dict] | None = None,
|
||||
) -> list[str]:
|
||||
"""Narrow the active tool list to categories relevant to this message.
|
||||
|
||||
Also scans the last assistant message in context_messages — this catches follow-up
|
||||
patterns like "yes, please do that" where the tool intent was expressed by the assistant
|
||||
in the prior turn and the user is simply confirming.
|
||||
|
||||
Returns [] if no keywords matched (zero tool overhead).
|
||||
Returns keyword-matched tools, intersected with role_tools if role_tools is set.
|
||||
"""
|
||||
scan_text = message
|
||||
if context_messages:
|
||||
for m in reversed(context_messages):
|
||||
if m.get("role") == "assistant":
|
||||
scan_text = scan_text + " " + (m.get("content") or "")
|
||||
break
|
||||
|
||||
matched = classify_tool_categories(scan_text)
|
||||
if not matched:
|
||||
return []
|
||||
|
||||
seen: set[str] = set()
|
||||
dynamic: list[str] = []
|
||||
for cat in matched:
|
||||
for t in CATEGORY_TOOL_MAP.get(cat, []):
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
dynamic.append(t)
|
||||
|
||||
if role_tools is not None:
|
||||
role_set = set(role_tools)
|
||||
dynamic = [t for t in dynamic if t in role_set]
|
||||
|
||||
return dynamic
|
||||
|
||||
253
cortex/tools/ae_database.py
Normal file
253
cortex/tools/ae_database.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Aether MariaDB tools — SELECT-only access to the Aether Platform database.
|
||||
|
||||
Credentials are read from the current user's channels.json:
|
||||
"aether_db": {
|
||||
"host": "192.168.64.5",
|
||||
"port": 3306,
|
||||
"name": "aether_dev",
|
||||
"user": "aether_dev",
|
||||
"password": "..."
|
||||
}
|
||||
|
||||
Configure per-user in Settings → Notifications (or edit channels.json directly).
|
||||
Only SELECT, SHOW, DESCRIBE, and EXPLAIN statements are permitted — no writes possible.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from google.genai import types
|
||||
|
||||
from auth_utils import get_user_channels
|
||||
from persona import get_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_ROWS = 200
|
||||
_MAX_CELL = 120
|
||||
_ALLOWED = {"select", "show", "describe", "desc", "explain"}
|
||||
_SAFE_ID = re.compile(r'^[a-zA-Z0-9_]+$')
|
||||
|
||||
|
||||
def _get_db_cfg() -> tuple[dict, str | None]:
|
||||
"""Return (cfg_dict, error_string). cfg is empty dict on error."""
|
||||
channels = get_user_channels(get_user())
|
||||
cfg = channels.get("aether_db") or {}
|
||||
if not cfg.get("host") or not cfg.get("user"):
|
||||
return {}, (
|
||||
"Aether DB not configured for this user. "
|
||||
"Add an 'aether_db' block to channels.json: "
|
||||
'{"host": "...", "port": 3306, "name": "aether_dev", "user": "...", "password": "..."}'
|
||||
)
|
||||
return cfg, None
|
||||
|
||||
|
||||
def _is_read_only(sql: str) -> bool:
|
||||
stripped = sql.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
first = stripped.split()[0].lower().rstrip(";")
|
||||
return first in _ALLOWED
|
||||
|
||||
|
||||
def _fmt(columns: list[str], rows: list[tuple]) -> str:
|
||||
if not rows:
|
||||
return f"({len(columns)} column{'s' if len(columns) != 1 else ''}, 0 rows)"
|
||||
|
||||
str_rows = [
|
||||
[("NULL" if v is None else str(v))[:_MAX_CELL] for v in row]
|
||||
for row in rows
|
||||
]
|
||||
|
||||
widths = [
|
||||
max([len(col)] + [len(r[i]) for r in str_rows])
|
||||
for i, col in enumerate(columns)
|
||||
]
|
||||
|
||||
sep = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
|
||||
header = "|" + "|".join(f" {c:<{w}} " for c, w in zip(columns, widths)) + "|"
|
||||
lines = [sep, header, sep]
|
||||
for row in str_rows:
|
||||
lines.append("|" + "|".join(f" {v:<{w}} " for v, w in zip(row, widths)) + "|")
|
||||
lines.append(sep)
|
||||
|
||||
note = " — results truncated at limit" if len(rows) == _MAX_ROWS else ""
|
||||
lines.append(f"({len(rows)} row{'s' if len(rows) != 1 else ''}{note})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _connect(cfg: dict):
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
return pymysql.connect(
|
||||
host=cfg["host"],
|
||||
port=int(cfg.get("port", 3306)),
|
||||
user=cfg["user"],
|
||||
password=cfg.get("password", ""),
|
||||
database=cfg.get("name", "aether_dev"),
|
||||
cursorclass=pymysql.cursors.Cursor,
|
||||
connect_timeout=10,
|
||||
)
|
||||
|
||||
|
||||
async def ae_db_query(sql: str) -> str:
|
||||
"""Run a read-only SQL query against the Aether MariaDB and return formatted results."""
|
||||
cfg, err = _get_db_cfg()
|
||||
if err:
|
||||
return err
|
||||
|
||||
if not _is_read_only(sql):
|
||||
first = sql.strip().split()[0] if sql.strip() else "(empty)"
|
||||
return f"Only SELECT, SHOW, DESCRIBE, and EXPLAIN are permitted. Got: {first!r}"
|
||||
|
||||
def _run() -> tuple[list[str], list[tuple]]:
|
||||
conn = _connect(cfg)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
columns = [d[0] for d in cur.description] if cur.description else []
|
||||
rows = list(cur.fetchmany(_MAX_ROWS))
|
||||
return columns, rows
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
columns, rows = await asyncio.to_thread(_run)
|
||||
return _fmt(columns, rows)
|
||||
except Exception as e:
|
||||
logger.warning("ae_db_query error: %s", e)
|
||||
return f"Query error: {e}"
|
||||
|
||||
|
||||
async def ae_db_describe(table: str, detailed: bool = False) -> str:
|
||||
"""Describe the columns of an Aether DB table or view."""
|
||||
cfg, err = _get_db_cfg()
|
||||
if err:
|
||||
return err
|
||||
|
||||
if not _SAFE_ID.match(table):
|
||||
return f"Invalid table name: {table!r}. Only letters, digits, and underscores allowed."
|
||||
|
||||
def _run():
|
||||
conn = _connect(cfg)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DESCRIBE `{table}`")
|
||||
columns = [d[0] for d in cur.description] if cur.description else []
|
||||
rows = list(cur.fetchall())
|
||||
return columns, rows
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
columns, rows = await asyncio.to_thread(_run)
|
||||
if not detailed:
|
||||
fields = [row[0] for row in rows]
|
||||
return f"{table}: " + ", ".join(fields)
|
||||
return _fmt(columns, rows)
|
||||
except Exception as e:
|
||||
logger.warning("ae_db_describe error: %s", e)
|
||||
return f"Describe error: {e}"
|
||||
|
||||
|
||||
async def ae_db_show_view(view_name: str) -> str:
|
||||
"""Return the CREATE VIEW SQL for an Aether DB view."""
|
||||
cfg, err = _get_db_cfg()
|
||||
if err:
|
||||
return err
|
||||
|
||||
if not _SAFE_ID.match(view_name):
|
||||
return f"Invalid view name: {view_name!r}. Only letters, digits, and underscores allowed."
|
||||
|
||||
def _run():
|
||||
conn = _connect(cfg)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SHOW CREATE VIEW `{view_name}`")
|
||||
return cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
row = await asyncio.to_thread(_run)
|
||||
if not row:
|
||||
return f"View not found: {view_name}"
|
||||
return str(row[1]) if len(row) > 1 else str(row[0])
|
||||
except Exception as e:
|
||||
logger.warning("ae_db_show_view error: %s", e)
|
||||
return f"Show view error: {e}"
|
||||
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="ae_db_describe",
|
||||
description=(
|
||||
"Describe the columns of an Aether Platform table or view. "
|
||||
"Returns a compact field list by default; pass detailed=true for full schema "
|
||||
"(type, nullability, default, key). Use to understand data structure before "
|
||||
"writing a SELECT query, or to answer 'what fields does X have?'. "
|
||||
"Examples: table='ae_journals'; table='clients'; table='time_entries'."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"table": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Table or view name (letters, digits, underscores only)",
|
||||
),
|
||||
"detailed": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description="Return full schema (type, nullability, key, default) instead of just field names",
|
||||
),
|
||||
},
|
||||
required=["table"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="ae_db_show_view",
|
||||
description=(
|
||||
"Return the CREATE VIEW SQL for an Aether Platform database view. "
|
||||
"Use to understand how a view is constructed before querying it, "
|
||||
"or to debug unexpected results from a view. "
|
||||
"Example: view_name='v_active_journals'."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"view_name": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="View name (letters, digits, underscores only)",
|
||||
),
|
||||
},
|
||||
required=["view_name"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="ae_db_query",
|
||||
description=(
|
||||
"Run a read-only SQL query against the Aether Platform MariaDB. "
|
||||
"Permitted statements: SELECT, SHOW, DESCRIBE, EXPLAIN. No writes are possible. "
|
||||
"Use for debugging: bad data, missing records, broken foreign keys, schema questions. "
|
||||
"Results capped at 200 rows; cells truncated at 120 chars. "
|
||||
"Examples: SELECT * FROM clients WHERE email = 'x@y.com'; "
|
||||
"SELECT COUNT(*) FROM time_entries WHERE billed = 0 AND deleted_at IS NULL; "
|
||||
"SHOW TABLES; DESCRIBE ae_journals; "
|
||||
"SELECT id_random, enable, deleted_at FROM ae_journals WHERE id_random = 'abc123'."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"sql": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description=(
|
||||
"SQL query to run — SELECT, SHOW, DESCRIBE, or EXPLAIN only. "
|
||||
"No semicolons required but harmless if present."
|
||||
),
|
||||
),
|
||||
},
|
||||
required=["sql"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +1,25 @@
|
||||
"""
|
||||
Agent spawning tool — lets the orchestrator launch sub-agents synchronously.
|
||||
Agent spawning and lifecycle tools.
|
||||
|
||||
Sub-agents run using the model assigned to the specified role. The call blocks
|
||||
until the sub-agent completes or times out.
|
||||
spawn_agent — synchronous or background sub-agent via any configured role model.
|
||||
agent_status / agent_list / agent_cancel — lifecycle management for background agents.
|
||||
|
||||
Supported model types: local_openai, gemini_api.
|
||||
claude_cli / gemini_cli are chat-only and do not support sub-agent tool loops.
|
||||
Sub-agents run using the model and tools assigned to the given role. The three-level
|
||||
hierarchy (Persona → Specialized → Support) is enforced by denying spawn_agent and
|
||||
aider_run at the L2→L3 boundary — Level 3 agents cannot delegate further.
|
||||
|
||||
Supported model types for sub-agents: local_openai, gemini_api.
|
||||
claude_cli / gemini_cli are chat-only and do not support tool-enabled sub-agents.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from google.genai import types
|
||||
|
||||
import agent_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-host semaphores — keyed by "host:<host_id>" or "type:<model_type>"
|
||||
@@ -20,6 +27,9 @@ logger = logging.getLogger(__name__)
|
||||
_semaphores: dict[str, asyncio.Semaphore] = {}
|
||||
_sem_lock = asyncio.Lock()
|
||||
|
||||
# Tools denied at the L2→L3 boundary so Level 3 agents cannot delegate further.
|
||||
_L3_DENY_TOOLS = ["spawn_agent", "aider_run"]
|
||||
|
||||
|
||||
async def _get_semaphore(key: str, max_concurrent: int) -> asyncio.Semaphore:
|
||||
"""Return (or create) the semaphore for a given host/type key."""
|
||||
@@ -35,12 +45,25 @@ async def spawn_agent(
|
||||
tier: int = 1,
|
||||
timeout: int = 120,
|
||||
max_rounds: int | None = None,
|
||||
allow_tools: list[str] | None = None,
|
||||
deny_tools: list[str] | None = None,
|
||||
background: bool = False,
|
||||
notify: bool = False,
|
||||
_agent_level: int = 2,
|
||||
) -> str:
|
||||
"""
|
||||
Spawn a sub-agent to complete a task synchronously.
|
||||
Spawn a sub-agent to complete a task.
|
||||
|
||||
The sub-agent uses the model and tools assigned to the given role. Returns
|
||||
the sub-agent's response as a string.
|
||||
In synchronous mode (background=False, the default): blocks until done and returns
|
||||
the result string.
|
||||
|
||||
In background mode (background=True): registers the agent, fires it as an asyncio
|
||||
background task, and returns an agent_id string immediately. Use agent_status() to
|
||||
poll, or set notify=True to receive a push notification on completion.
|
||||
|
||||
Level enforcement: this agent (level _agent_level) spawns children at level+1.
|
||||
Children at level 3 automatically have spawn_agent and aider_run denied so they
|
||||
cannot delegate further.
|
||||
"""
|
||||
import model_registry
|
||||
from context_loader import load_context
|
||||
@@ -91,6 +114,30 @@ async def spawn_agent(
|
||||
confirm_allow = set(policy.get("allow", []))
|
||||
confirm_deny = set(policy.get("deny", []))
|
||||
|
||||
# Per-call tool restrictions — role config remains the authoritative ceiling
|
||||
if allow_tools is not None:
|
||||
if tool_list is not None:
|
||||
tool_list = [t for t in tool_list if t in allow_tools]
|
||||
else:
|
||||
tool_list = list(allow_tools)
|
||||
|
||||
if deny_tools is not None:
|
||||
deny_set = set(deny_tools)
|
||||
if tool_list is not None:
|
||||
tool_list = [t for t in tool_list if t not in deny_set]
|
||||
else:
|
||||
confirm_deny = confirm_deny | deny_set
|
||||
|
||||
# Level enforcement: children of this agent are at level _agent_level + 1.
|
||||
# Level 3 children cannot delegate — auto-deny the spawning tools.
|
||||
child_level = _agent_level + 1
|
||||
if child_level >= 3:
|
||||
l3_deny = set(_L3_DENY_TOOLS)
|
||||
if tool_list is not None:
|
||||
tool_list = [t for t in tool_list if t not in l3_deny]
|
||||
else:
|
||||
confirm_deny = confirm_deny | l3_deny
|
||||
|
||||
if max_rounds is not None:
|
||||
model_cfg = dict(model_cfg)
|
||||
model_cfg["max_rounds"] = max_rounds
|
||||
@@ -141,6 +188,41 @@ async def spawn_agent(
|
||||
)
|
||||
return result.response or "(sub-agent returned no output)"
|
||||
|
||||
if background:
|
||||
rec = await agent_manager.register(
|
||||
user=user,
|
||||
role=role,
|
||||
task=task,
|
||||
level=_agent_level,
|
||||
notify=notify,
|
||||
)
|
||||
|
||||
async def _bg_task() -> None:
|
||||
async with sem:
|
||||
try:
|
||||
logger.info(
|
||||
"spawn_agent [bg]: %s role=%s level=%d timeout=%ds",
|
||||
rec.agent_id[:8], role, _agent_level, timeout,
|
||||
)
|
||||
result = await asyncio.wait_for(_run(), timeout=float(timeout))
|
||||
await agent_manager.finish(rec.agent_id, result, "done")
|
||||
logger.info("spawn_agent [bg]: done %s", rec.agent_id[:8])
|
||||
except asyncio.CancelledError:
|
||||
await agent_manager.finish(rec.agent_id, "Cancelled.", "cancelled")
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
msg = f"Sub-agent timed out after {timeout}s (role={role})"
|
||||
logger.warning("spawn_agent [bg]: timeout %s", rec.agent_id[:8])
|
||||
await agent_manager.finish(rec.agent_id, msg, "timeout")
|
||||
except Exception as e:
|
||||
logger.exception("spawn_agent [bg]: failed %s", rec.agent_id[:8])
|
||||
await agent_manager.finish(rec.agent_id, str(e), "failed")
|
||||
|
||||
bg = asyncio.create_task(_bg_task())
|
||||
agent_manager.set_task_ref(rec.agent_id, bg)
|
||||
return f"Agent started in background. ID: {rec.agent_id}\nUse agent_status('{rec.agent_id}') to check progress."
|
||||
|
||||
# Synchronous path — unchanged behaviour
|
||||
async with sem:
|
||||
try:
|
||||
logger.info(
|
||||
@@ -158,14 +240,84 @@ async def spawn_agent(
|
||||
return f"Sub-agent error ({role}): {e}"
|
||||
|
||||
|
||||
# ── Agent lifecycle tools ─────────────────────────────────────────────────────
|
||||
|
||||
async def agent_status(agent_id: str) -> str:
|
||||
"""Return the status and result preview of a background agent."""
|
||||
from persona import get_user
|
||||
user = get_user() or "unknown"
|
||||
rec = agent_manager.get(agent_id)
|
||||
if not rec:
|
||||
return f"No agent found with ID: {agent_id}"
|
||||
if rec.user != user:
|
||||
return "Access denied."
|
||||
|
||||
now = datetime.now()
|
||||
end = rec.finished or now
|
||||
elapsed = int((end - rec.started).total_seconds())
|
||||
|
||||
lines = [
|
||||
f"Agent {rec.agent_id[:8]}…",
|
||||
f" Status: {rec.status}",
|
||||
f" Role: {rec.role} (Level {rec.level})",
|
||||
f" Elapsed: {elapsed}s",
|
||||
f" Started: {rec.started.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f" Task: {rec.task}",
|
||||
]
|
||||
if rec.parent_id:
|
||||
lines.append(f" Parent: {rec.parent_id[:8]}…")
|
||||
if rec.result is not None:
|
||||
lines.append(f" Result: {rec.result[:300]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def agent_list(status: str | None = None, limit: int = 10) -> str:
|
||||
"""List background agents for the current user."""
|
||||
from persona import get_user
|
||||
user = get_user() or "unknown"
|
||||
limit = min(max(int(limit), 1), 50)
|
||||
records = agent_manager.list_agents(user, status=status, limit=limit)
|
||||
|
||||
if not records:
|
||||
suffix = f" (filter: status={status})" if status else ""
|
||||
return f"No agents found.{suffix}"
|
||||
|
||||
now = datetime.now()
|
||||
lines = []
|
||||
for rec in records:
|
||||
end = rec.finished or now
|
||||
elapsed = int((end - rec.started).total_seconds())
|
||||
preview = rec.task[:60].replace("\n", " ")
|
||||
result_hint = f" → {rec.result[:50]}" if rec.result else ""
|
||||
lines.append(
|
||||
f"[{rec.agent_id[:8]}] {rec.status:<10s} L{rec.level} "
|
||||
f"{rec.role:<12s} {elapsed:>5}s {preview}{result_hint}"
|
||||
)
|
||||
|
||||
header = f"{len(records)} agent(s)" + (f" (status={status})" if status else "") + ":"
|
||||
return header + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def agent_cancel(agent_id: str) -> str:
|
||||
"""Cancel a running background agent."""
|
||||
from persona import get_user
|
||||
user = get_user() or "unknown"
|
||||
return await agent_manager.cancel_agent(agent_id, user)
|
||||
|
||||
|
||||
# ── Declarations ──────────────────────────────────────────────────────────────
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="spawn_agent",
|
||||
description=(
|
||||
"Spawn a sub-agent to complete a task synchronously. "
|
||||
"Spawn a sub-agent to complete a task. "
|
||||
"In synchronous mode (default): blocks until the sub-agent finishes and returns its response. "
|
||||
"In background mode (background=True): fires the agent asynchronously and returns an agent_id "
|
||||
"immediately — use agent_status() to check progress or set notify=True for a completion alert. "
|
||||
"The sub-agent uses the model and tool set assigned to the given role. "
|
||||
"Use for processing pipelines, parallel analysis, or delegating "
|
||||
"specialized work (research, coding, data migration, etc.)."
|
||||
"Use for processing pipelines, parallel analysis, or delegating specialized work "
|
||||
"(research, coding, data migration, etc.)."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
@@ -192,14 +344,103 @@ DECLARATIONS = [
|
||||
),
|
||||
"timeout": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Max seconds to wait (default 120).",
|
||||
description="Max seconds to wait (default 120). Applies in both sync and background mode.",
|
||||
),
|
||||
"max_rounds": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Override max tool-loop iterations for this call.",
|
||||
),
|
||||
"allow_tools": types.Schema(
|
||||
type=types.Type.ARRAY,
|
||||
items=types.Schema(type=types.Type.STRING),
|
||||
description=(
|
||||
"Restrict the sub-agent to only these tools. "
|
||||
"Intersected with the role's tool set — cannot grant more than the role allows. "
|
||||
"Example: ['web_search', 'web_read'] for a pure research agent."
|
||||
),
|
||||
),
|
||||
"deny_tools": types.Schema(
|
||||
type=types.Type.ARRAY,
|
||||
items=types.Schema(type=types.Type.STRING),
|
||||
description=(
|
||||
"Block these tools from the sub-agent regardless of role config. "
|
||||
"Example: ['shell_exec', 'file_write', 'cortex_restart']."
|
||||
),
|
||||
),
|
||||
"background": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description=(
|
||||
"Run asynchronously in the background (default: false). "
|
||||
"When true, returns an agent_id immediately instead of blocking for the result. "
|
||||
"Use agent_status(agent_id) to check progress. "
|
||||
"Best for tasks that take more than ~30 seconds."
|
||||
),
|
||||
),
|
||||
"notify": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description=(
|
||||
"Send a push/Talk notification when the background agent completes (default: false). "
|
||||
"Only meaningful when background=true."
|
||||
),
|
||||
),
|
||||
},
|
||||
required=["task"],
|
||||
),
|
||||
)
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="agent_status",
|
||||
description=(
|
||||
"Get the current status of a background agent by ID. "
|
||||
"Returns status (running/done/failed/cancelled/timeout), role, elapsed time, "
|
||||
"task description, and result preview."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"agent_id": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="The agent ID returned by spawn_agent(background=True) or aider_run(background=True).",
|
||||
),
|
||||
},
|
||||
required=["agent_id"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="agent_list",
|
||||
description=(
|
||||
"List background agents for the current user. "
|
||||
"Returns recent agents with ID, status, role, level, elapsed time, and task preview. "
|
||||
"Use to survey what's running or recently completed."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"status": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Filter by status: 'running', 'done', 'failed', 'cancelled', 'timeout'. Omit for all.",
|
||||
),
|
||||
"limit": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Max agents to return (default 10, max 50).",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="agent_cancel",
|
||||
description=(
|
||||
"Cancel a running background agent. ADMIN ONLY. Requires confirmation. "
|
||||
"Use agent_list() to find the agent ID first."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"agent_id": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="The agent ID to cancel.",
|
||||
),
|
||||
},
|
||||
required=["agent_id"],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
406
cortex/tools/aider.py
Normal file
406
cortex/tools/aider.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
Aider coding agent tool — invokes Aider AI pair programming as a subprocess.
|
||||
|
||||
Aider handles repo-map generation, file editing, git commits, and linting automatically.
|
||||
It works with any OpenAI-compatible model — point it at DeepSeek, Ollama, OpenRouter, etc.
|
||||
via AIDER_MODEL / AIDER_OPENAI_API_BASE env vars or the project's .aider.conf.yml.
|
||||
|
||||
Credentials are pulled automatically from the Cortex model registry:
|
||||
- Named cloud providers (OpenRouter, OpenAI, Groq, Anthropic, …) → --api-key slug=key
|
||||
- Generic OpenAI-compatible hosts (Open WebUI, Ollama, local) → --openai-api-base + key
|
||||
- Anthropic from providers.anthropic.credentials → --api-key anthropic=key
|
||||
|
||||
background=True runs the subprocess asynchronously and returns an agent_id immediately.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from google.genai import types
|
||||
|
||||
import agent_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CORTEX_DIR = Path(__file__).parent # .../Cortex_and_Inara_dev/cortex/
|
||||
_PROJECT_ROOT = _CORTEX_DIR.parent # .../Cortex_and_Inara_dev/
|
||||
|
||||
# Known project aliases — expand before passing to subprocess
|
||||
_PROJECT_ALIASES: dict[str, str] = {
|
||||
"cortex": str(_PROJECT_ROOT),
|
||||
"aether_api": "~/OSIT_dev/aether_api_fastapi",
|
||||
"aether_frontend": "~/OSIT_dev/aether_app_sveltekit",
|
||||
"aether_container": "~/OSIT_dev/aether_container_env",
|
||||
}
|
||||
|
||||
_MAX_OUTPUT_CHARS = 12_000
|
||||
|
||||
# Maps URL fragments → Aider --api-key provider slug.
|
||||
# Order matters: more specific patterns first.
|
||||
_CLOUD_PROVIDER_URL_MAP: list[tuple[str, str]] = [
|
||||
("openrouter.ai", "openrouter"),
|
||||
("api.openai.com", "openai"),
|
||||
("groq.com", "groq"),
|
||||
("api.together.xyz", "togetherai"),
|
||||
("fireworks.ai", "fireworks"),
|
||||
("api.x.ai", "xai"),
|
||||
("api.deepseek.com", "deepseek"),
|
||||
("api.mistral.ai", "mistral"),
|
||||
]
|
||||
|
||||
|
||||
def _provider_slug(api_url: str) -> str | None:
|
||||
"""Return the Aider --api-key provider slug for a known cloud URL, None for generic."""
|
||||
url_lower = api_url.lower()
|
||||
for fragment, slug in _CLOUD_PROVIDER_URL_MAP:
|
||||
if fragment in url_lower:
|
||||
return slug
|
||||
return None
|
||||
|
||||
|
||||
def _host_flags(host: dict, model: str | None) -> tuple[list[str], str | None]:
|
||||
"""Build Aider credential flags for a specific host entry.
|
||||
|
||||
Returns (extra_args, adjusted_model). For generic (local) endpoints the model
|
||||
name may be prefixed with 'openai/' so Aider routes through the OpenAI client.
|
||||
"""
|
||||
api_url = (host.get("api_url") or "").rstrip("/")
|
||||
api_key = host.get("api_key") or "none"
|
||||
host_type = host.get("host_type", "openai")
|
||||
slug = _provider_slug(api_url)
|
||||
|
||||
if slug:
|
||||
# Named cloud provider — Aider maps --api-key slug=key → SLUG_API_KEY env var
|
||||
flags = ["--api-key", f"{slug}={api_key}"] if api_key and api_key != "none" else []
|
||||
return flags, model
|
||||
|
||||
# Generic OpenAI-compatible (local Open WebUI, Ollama, custom)
|
||||
base_url = api_url
|
||||
if host_type == "openwebui":
|
||||
# Open WebUI serves the chat endpoint at /api/chat/completions
|
||||
base_url = base_url + "/api"
|
||||
|
||||
flags = ["--openai-api-base", base_url, "--openai-api-key", api_key]
|
||||
|
||||
# Prefix model with 'openai/' for generic endpoints when no provider prefix is set
|
||||
adj_model = model
|
||||
if model and "/" not in model:
|
||||
adj_model = f"openai/{model}"
|
||||
|
||||
return flags, adj_model
|
||||
|
||||
|
||||
def _resolve_credentials(
|
||||
registry: dict,
|
||||
model: str | None,
|
||||
host_label: str | None,
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Determine Aider credential flags and (possibly adjusted) model name.
|
||||
|
||||
Resolution order:
|
||||
1. Anthropic model hint (claude-* / anthropic/*) → Anthropic API key
|
||||
2. Explicit host_label → that host's credentials
|
||||
3. Model prefix hint (openrouter/*, groq/*, …) → matching host
|
||||
4. Default priority: OpenRouter → Anthropic → any keyed cloud host → local host
|
||||
|
||||
Returns (extra_args, adjusted_model).
|
||||
"""
|
||||
hosts = registry.get("hosts", [])
|
||||
|
||||
# Extract Anthropic key from providers.anthropic.credentials (not a host entry)
|
||||
anthropic_key = None
|
||||
for cred in registry.get("providers", {}).get("anthropic", {}).get("credentials", []):
|
||||
if cred.get("api_key"):
|
||||
anthropic_key = cred["api_key"]
|
||||
break
|
||||
|
||||
# ── 1. Anthropic model hint ────────────────────────────────────────────────
|
||||
if model and any(h in model.lower() for h in ("claude-", "anthropic/")):
|
||||
if anthropic_key:
|
||||
logger.debug("aider: Anthropic model detected — using Anthropic API key")
|
||||
return ["--api-key", f"anthropic={anthropic_key}"], model
|
||||
|
||||
# ── 2. Explicit host_label override ───────────────────────────────────────
|
||||
if host_label:
|
||||
ll = host_label.lower()
|
||||
host = next((h for h in hosts if ll in h.get("label", "").lower()), None)
|
||||
if host:
|
||||
logger.debug("aider: using explicitly requested host '%s'", host.get("label"))
|
||||
return _host_flags(host, model)
|
||||
|
||||
# ── 3. Model prefix hints ─────────────────────────────────────────────────
|
||||
if model:
|
||||
ml = model.lower()
|
||||
for fragment, slug in _CLOUD_PROVIDER_URL_MAP:
|
||||
if ml.startswith(slug + "/") or ml.startswith(fragment):
|
||||
host = next(
|
||||
(h for h in hosts if fragment in h.get("api_url", "").lower()), None
|
||||
)
|
||||
if host:
|
||||
logger.debug("aider: model prefix '%s' → host '%s'", slug, host.get("label"))
|
||||
return _host_flags(host, model)
|
||||
|
||||
# ── 4. Default priority ───────────────────────────────────────────────────
|
||||
# OpenRouter first (most model coverage)
|
||||
or_host = next((h for h in hosts if "openrouter.ai" in h.get("api_url", "")), None)
|
||||
if or_host and or_host.get("api_key"):
|
||||
logger.debug("aider: defaulting to OpenRouter")
|
||||
return _host_flags(or_host, model)
|
||||
|
||||
# Anthropic API key (no model hint but it's configured)
|
||||
if anthropic_key:
|
||||
logger.debug("aider: defaulting to Anthropic API key")
|
||||
return ["--api-key", f"anthropic={anthropic_key}"], model
|
||||
|
||||
# Any other keyed cloud host
|
||||
for host in hosts:
|
||||
slug = _provider_slug(host.get("api_url", ""))
|
||||
if slug and host.get("api_key"):
|
||||
logger.debug("aider: using keyed cloud host '%s'", host.get("label"))
|
||||
return _host_flags(host, model)
|
||||
|
||||
# Generic / local host (no key or unknown provider)
|
||||
for host in hosts:
|
||||
flags, adj_model = _host_flags(host, model)
|
||||
if flags:
|
||||
logger.debug("aider: using local host '%s'", host.get("label"))
|
||||
return flags, adj_model
|
||||
|
||||
logger.debug("aider: no credentials found in registry — relying on env vars / .aider.conf.yml")
|
||||
return [], model
|
||||
|
||||
|
||||
async def aider_run(
|
||||
project: str,
|
||||
task: str,
|
||||
files: list[str] | None = None,
|
||||
model: str | None = None,
|
||||
host_label: str | None = None,
|
||||
auto_commit: bool = True,
|
||||
timeout: int = 300,
|
||||
background: bool = False,
|
||||
notify: bool = False,
|
||||
) -> str:
|
||||
"""Run Aider with a single task in a project directory, then exit.
|
||||
|
||||
Credentials are resolved automatically from the Cortex model registry. Use
|
||||
host_label to pick a specific configured host (e.g. 'OpenRouter', 'Local').
|
||||
|
||||
When background=True, fires the subprocess asynchronously and returns an agent_id
|
||||
immediately. Use agent_status(agent_id) to check progress; set notify=True to
|
||||
receive a push/Talk notification on completion.
|
||||
"""
|
||||
resolved = _PROJECT_ALIASES.get(project, project)
|
||||
cwd = Path(os.path.expanduser(resolved))
|
||||
|
||||
if not cwd.is_dir():
|
||||
return f"Error: project directory '{resolved}' does not exist."
|
||||
|
||||
timeout = min(max(int(timeout), 10), 600)
|
||||
|
||||
# Resolve credentials before building the command (model name may be adjusted)
|
||||
user = "scott"
|
||||
extra_cred_flags: list[str] = []
|
||||
try:
|
||||
import model_registry
|
||||
from persona import get_user
|
||||
user = get_user() or "scott"
|
||||
registry = model_registry.get_registry(user)
|
||||
extra_cred_flags, model = _resolve_credentials(registry, model, host_label)
|
||||
except Exception as e:
|
||||
logger.debug("aider: credential resolution failed (%s) — relying on env", e)
|
||||
|
||||
cmd: list[str] = [
|
||||
"aider",
|
||||
"--message", task,
|
||||
"--yes-always",
|
||||
"--no-pretty",
|
||||
"--no-stream",
|
||||
"--no-check-update",
|
||||
"--no-detect-urls",
|
||||
"--auto-commits" if auto_commit else "--no-auto-commits",
|
||||
]
|
||||
|
||||
cmd += extra_cred_flags
|
||||
|
||||
if model:
|
||||
cmd += ["--model", model]
|
||||
|
||||
for f in (files or []):
|
||||
cmd += ["--file", f]
|
||||
|
||||
logger.info(
|
||||
"aider_run: project=%s model=%s host_label=%s auto_commit=%s background=%s task=%.120s",
|
||||
project, model, host_label, auto_commit, background, task,
|
||||
)
|
||||
|
||||
async def _run() -> str:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(cwd),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=float(timeout))
|
||||
|
||||
out = stdout.decode(errors="replace").strip()
|
||||
err = stderr.decode(errors="replace").strip()
|
||||
|
||||
parts = []
|
||||
if out:
|
||||
parts.append(out)
|
||||
if err:
|
||||
parts.append(f"[stderr]\n{err}")
|
||||
combined = "\n".join(parts) if parts else "(no output)"
|
||||
|
||||
if len(combined) > _MAX_OUTPUT_CHARS:
|
||||
half = _MAX_OUTPUT_CHARS // 2
|
||||
combined = (
|
||||
combined[:half]
|
||||
+ f"\n\n[... {len(combined) - _MAX_OUTPUT_CHARS} chars trimmed ...]\n\n"
|
||||
+ combined[-half:]
|
||||
)
|
||||
|
||||
if proc.returncode not in (0, 1):
|
||||
return f"[exit {proc.returncode}]\n{combined}"
|
||||
return combined
|
||||
|
||||
if background:
|
||||
rec = await agent_manager.register(
|
||||
user=user,
|
||||
role="aider",
|
||||
task=task,
|
||||
level=2,
|
||||
notify=notify,
|
||||
)
|
||||
|
||||
async def _bg_task() -> None:
|
||||
try:
|
||||
result = await _run()
|
||||
await agent_manager.finish(rec.agent_id, result, "done")
|
||||
logger.info("aider_run [bg]: done %s", rec.agent_id[:8])
|
||||
except asyncio.CancelledError:
|
||||
await agent_manager.finish(rec.agent_id, "Cancelled.", "cancelled")
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
msg = f"Aider timed out after {timeout}s"
|
||||
logger.warning("aider_run [bg]: timeout %s", rec.agent_id[:8])
|
||||
await agent_manager.finish(rec.agent_id, msg, "timeout")
|
||||
except FileNotFoundError:
|
||||
msg = "Error: 'aider' not found in PATH — run: pip install aider-chat"
|
||||
await agent_manager.finish(rec.agent_id, msg, "failed")
|
||||
except Exception as e:
|
||||
logger.error("aider_run [bg]: failed %s: %s", rec.agent_id[:8], e)
|
||||
await agent_manager.finish(rec.agent_id, str(e), "failed")
|
||||
|
||||
bg = asyncio.create_task(_bg_task())
|
||||
agent_manager.set_task_ref(rec.agent_id, bg)
|
||||
return (
|
||||
f"Aider task started in background. ID: {rec.agent_id}\n"
|
||||
f"Use agent_status('{rec.agent_id}') to monitor progress."
|
||||
)
|
||||
|
||||
# Synchronous path
|
||||
try:
|
||||
return await _run()
|
||||
except asyncio.TimeoutError:
|
||||
return f"Error: aider timed out after {timeout}s"
|
||||
except FileNotFoundError:
|
||||
return "Error: 'aider' not found in PATH — run: pip install aider-chat"
|
||||
except Exception as e:
|
||||
logger.error("aider_run error: %s", e)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="aider_run",
|
||||
description=(
|
||||
"Run the Aider AI coding agent on a project with a single task, then exit. "
|
||||
"Aider maps the repo, edits files, runs lint checks, and optionally commits. "
|
||||
"Credentials are resolved automatically from the Cortex model registry — "
|
||||
"OpenRouter, local Open WebUI/Ollama, Anthropic API, and other configured hosts "
|
||||
"are all supported. Use host_label to pick a specific host. "
|
||||
"Set background=True for long tasks — returns an agent_id immediately and sends "
|
||||
"a notification when done. ADMIN ONLY. Requires confirmation."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"project": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description=(
|
||||
"Project alias or absolute path. Known aliases: "
|
||||
"'cortex' (this project), 'aether_api', 'aether_frontend', "
|
||||
"'aether_container'. Or provide an absolute path."
|
||||
),
|
||||
),
|
||||
"task": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description=(
|
||||
"Full task description sent to Aider as --message. "
|
||||
"Be specific — include file names, what to change, and why."
|
||||
),
|
||||
),
|
||||
"files": types.Schema(
|
||||
type=types.Type.ARRAY,
|
||||
items=types.Schema(type=types.Type.STRING),
|
||||
description=(
|
||||
"Optional files to add explicitly to the editing context "
|
||||
"(paths relative to project root). Aider builds a repo map "
|
||||
"automatically — these get priority."
|
||||
),
|
||||
),
|
||||
"model": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description=(
|
||||
"Optional model override. Format depends on the provider: "
|
||||
"'openrouter/anthropic/claude-3-5-haiku-20241022' (OpenRouter), "
|
||||
"'claude-3-5-sonnet-20241022' (Anthropic direct), "
|
||||
"'gemma-4-27b-it' or 'openai/gemma-4-27b-it' (local Open WebUI), "
|
||||
"'deepseek/deepseek-chat' (DeepSeek via OpenRouter). "
|
||||
"Defaults to the project's .aider.conf.yml model or AIDER_MODEL env var."
|
||||
),
|
||||
),
|
||||
"host_label": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description=(
|
||||
"Pick a specific configured host by label (partial match, case-insensitive). "
|
||||
"Examples: 'OpenRouter', 'Local', 'scott-lt-i7-rtx'. "
|
||||
"Overrides automatic credential resolution. "
|
||||
"Omit to let credentials be chosen automatically."
|
||||
),
|
||||
),
|
||||
"auto_commit": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description=(
|
||||
"Auto-commit changes after edits (default: true). "
|
||||
"Set to false to review diffs before committing manually."
|
||||
),
|
||||
),
|
||||
"timeout": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Max seconds to wait for Aider to finish (default 300, max 600).",
|
||||
),
|
||||
"background": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description=(
|
||||
"Run asynchronously in the background (default: false). "
|
||||
"Returns an agent_id immediately; use agent_status(agent_id) to monitor. "
|
||||
"Recommended for tasks expected to take more than ~60 seconds."
|
||||
),
|
||||
),
|
||||
"notify": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description=(
|
||||
"Send a push/Talk notification when the background task completes "
|
||||
"(default: false). Only applies when background=true."
|
||||
),
|
||||
),
|
||||
},
|
||||
required=["project", "task"],
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -58,8 +58,9 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
|
||||
except ValueError as e:
|
||||
return f"Bad schedule: {e}"
|
||||
|
||||
if job_type not in ("remind", "note"):
|
||||
return "Bad type: must be 'remind' or 'note'."
|
||||
_VALID_TYPES = ("remind", "note", "message", "brief", "task")
|
||||
if job_type not in _VALID_TYPES:
|
||||
return f"Bad type: must be one of {', '.join(_VALID_TYPES)}."
|
||||
|
||||
current_user = get_user()
|
||||
current_persona = get_persona()
|
||||
@@ -210,18 +211,27 @@ DECLARATIONS = [
|
||||
name="cron_add",
|
||||
description=(
|
||||
"Create a new scheduled cron job and register it immediately (no restart needed). "
|
||||
"Two types: 'remind' writes to the pending reminders queue (Inara sees it automatically "
|
||||
"in context next session); 'note' appends to the scratchpad. "
|
||||
"Schedule formats: 'hourly' | 'daily' | 'daily:HH:MM' | 'weekly:DOW' | 'weekly:DOW:HH:MM'. "
|
||||
"Example: schedule='daily:09:00', type='remind', payload='Check in with Scott.'"
|
||||
"Job types: "
|
||||
"'remind' — appends to REMINDERS.md, auto-surfaced in chat context at tier 2+; "
|
||||
"'note' — appends to SCRATCH.md, read on demand; "
|
||||
"'message' — sends payload text directly to the user's notification channel; "
|
||||
"'brief' — calls the LLM (no tools) with payload as the prompt, sends the response; "
|
||||
"'task' — runs the full orchestrator tool loop with payload as the request, sends "
|
||||
"Claude's response to the notification channel (use for agentic scheduled work: "
|
||||
"research, checks, file updates, summaries that need tool access). "
|
||||
"Schedule formats: 'hourly' | 'daily' | 'daily:HH:MM' | 'weekly:DOW' | 'weekly:DOW:HH:MM' | "
|
||||
"'monthly' | 'monthly:DD' | 'monthly:DD:HH:MM' | 'yearly:MM:DD' | 'yearly:MM:DD:HH:MM'. "
|
||||
"Examples: schedule='weekly:mon:08:00' for Monday briefings; "
|
||||
"schedule='monthly:1:09:00' for a first-of-month review; "
|
||||
"schedule='yearly:03:15' for a March 15 birthday reminder."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"label": types.Schema(type=types.Type.STRING, description="Short human-readable name for this job (e.g. 'Morning check-in')"),
|
||||
"schedule": types.Schema(type=types.Type.STRING, description="When to run. Formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM (e.g. 'weekly:mon:09:00')"),
|
||||
"job_type": types.Schema(type=types.Type.STRING, description="'remind' (→ REMINDERS.md, auto-surfaced in context) or 'note' (→ SCRATCH.md)"),
|
||||
"payload": types.Schema(type=types.Type.STRING, description="The text to write when the job fires"),
|
||||
"label": types.Schema(type=types.Type.STRING, description="Short human-readable name for this job (e.g. 'Monday task summary')"),
|
||||
"schedule": types.Schema(type=types.Type.STRING, description="When to run: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM | monthly | monthly:DD | monthly:DD:HH:MM | yearly:MM:DD | yearly:MM:DD:HH:MM"),
|
||||
"job_type": types.Schema(type=types.Type.STRING, description="remind | note | message | brief | task"),
|
||||
"payload": types.Schema(type=types.Type.STRING, description="The text/prompt to use when the job fires"),
|
||||
},
|
||||
required=["label", "schedule", "job_type", "payload"],
|
||||
),
|
||||
|
||||
@@ -339,6 +339,45 @@ def _sync_file_grep(path_str: str, pattern: str, context_lines: int, recursive:
|
||||
return header + "\n\n" + "\n\n".join(sections)
|
||||
|
||||
|
||||
async def file_diff(path_a: str, path_b: str) -> str:
|
||||
"""Compare two files and return a unified diff."""
|
||||
return await asyncio.to_thread(_sync_file_diff, path_a, path_b)
|
||||
|
||||
|
||||
def _sync_file_diff(path_a: str, path_b: str) -> str:
|
||||
try:
|
||||
resolved_a = Path(path_a).expanduser().resolve()
|
||||
resolved_b = Path(path_b).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
for resolved in (resolved_a, resolved_b):
|
||||
if not _is_project_allowed(resolved):
|
||||
return f"Access denied: {resolved}"
|
||||
if not resolved.exists():
|
||||
return f"File not found: {resolved}"
|
||||
if not resolved.is_file():
|
||||
return f"Not a file: {resolved}"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["diff", "-u", str(resolved_a), str(resolved_b)],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return f"Files are identical: {resolved_a.name} vs {resolved_b.name}"
|
||||
output = result.stdout
|
||||
if not output:
|
||||
return f"diff returned no output (exit {result.returncode}): {result.stderr}"
|
||||
if len(output) > _MAX_BYTES:
|
||||
output = output[:_MAX_BYTES] + "\n… [truncated]"
|
||||
return output
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Timeout running diff"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
async def file_syntax_check(path: str) -> str:
|
||||
"""Check syntax of a Python (.py) or JSON (.json) file."""
|
||||
return await asyncio.to_thread(_sync_file_syntax_check, path)
|
||||
@@ -604,6 +643,30 @@ DECLARATIONS = [
|
||||
required=["path", "pattern"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="file_diff",
|
||||
description=(
|
||||
"Compare two files and return a unified diff (diff -u). "
|
||||
"Use for code review, verifying what changed between two versions of a file, "
|
||||
"or comparing config files side-by-side. "
|
||||
"Returns 'Files are identical' if there are no differences. "
|
||||
"Restricted to the Cortex project directory."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path_a": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Path to the first file (the 'before' or reference file)",
|
||||
),
|
||||
"path_b": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Path to the second file (the 'after' or comparison file)",
|
||||
),
|
||||
},
|
||||
required=["path_a", "path_b"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="file_syntax_check",
|
||||
description=(
|
||||
|
||||
158
cortex/tools/git.py
Normal file
158
cortex/tools/git.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Git inspection tools — project-scoped, read-only.
|
||||
|
||||
git_status — working tree status (staged, unstaged, untracked changes)
|
||||
git_log — recent commit history with optional path filter
|
||||
git_diff — diff between commits, branches, or working tree vs HEAD
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from google.genai import types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.resolve()
|
||||
_MAX_OUTPUT = 50_000
|
||||
|
||||
|
||||
async def _git(*args: str, timeout: int = 15) -> tuple[int, str]:
|
||||
"""Run a git command in the project root. Returns (returncode, output)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", str(_PROJECT_ROOT), *args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return 1, "git command timed out"
|
||||
out = (stdout or b"").decode(errors="replace").strip()
|
||||
err = (stderr or b"").decode(errors="replace").strip()
|
||||
combined = out if out else err
|
||||
return proc.returncode, combined
|
||||
|
||||
|
||||
def _cap(text: str) -> str:
|
||||
if len(text) > _MAX_OUTPUT:
|
||||
return text[:_MAX_OUTPUT] + "\n… [truncated]"
|
||||
return text
|
||||
|
||||
|
||||
async def git_status() -> str:
|
||||
"""Return the current git working tree status."""
|
||||
rc, out = await _git("status")
|
||||
if rc != 0:
|
||||
return f"git status failed: {out}"
|
||||
return out or "Working tree clean — nothing to report."
|
||||
|
||||
|
||||
async def git_log(n: int = 20, path: str = "", oneline: bool = True) -> str:
|
||||
"""Return recent git commit history."""
|
||||
args = ["log"]
|
||||
if oneline:
|
||||
args += ["--oneline"]
|
||||
else:
|
||||
args += ["--format=%H %as %an%n %s", "--date=short"]
|
||||
args += [f"-{max(1, min(n, 200))}"]
|
||||
if path:
|
||||
args += ["--", path]
|
||||
rc, out = await _git(*args)
|
||||
if rc != 0:
|
||||
return f"git log failed: {out}"
|
||||
return _cap(out) or "No commits found."
|
||||
|
||||
|
||||
async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False) -> str:
|
||||
"""Show a git diff. Defaults to working tree vs HEAD (unstaged changes)."""
|
||||
args = ["diff"]
|
||||
if stat_only:
|
||||
args += ["--stat"]
|
||||
if ref_a and ref_b:
|
||||
args += [f"{ref_a}..{ref_b}"]
|
||||
elif ref_a:
|
||||
args += [ref_a]
|
||||
if path:
|
||||
args += ["--", path]
|
||||
rc, out = await _git(*args)
|
||||
# diff exits 1 when there are differences — that's normal
|
||||
if rc not in (0, 1):
|
||||
return f"git diff failed: {out}"
|
||||
return _cap(out) or "No differences found."
|
||||
|
||||
|
||||
# ── Declarations ──────────────────────────────────────────────────────────────
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="git_status",
|
||||
description=(
|
||||
"Show the current git working tree status for the Cortex project: "
|
||||
"staged changes, unstaged modifications, and untracked files. "
|
||||
"Use to check whether there are uncommitted changes before restarting or deploying."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="git_log",
|
||||
description=(
|
||||
"Show recent git commit history for the Cortex project. "
|
||||
"Returns commit hashes, dates, and messages. "
|
||||
"Optionally filter to a specific file or directory path."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"n": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Number of commits to return (default 20, max 200)",
|
||||
),
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Optional file or directory path to filter commits by",
|
||||
),
|
||||
"oneline": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description="Use compact one-line format (default true). Set false for more detail.",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="git_diff",
|
||||
description=(
|
||||
"Show a git diff for the Cortex project. "
|
||||
"With no arguments: shows unstaged working tree changes vs HEAD. "
|
||||
"With ref_a only: shows changes between that ref and HEAD. "
|
||||
"With ref_a and ref_b: shows changes between the two refs (commits, branches, or tags). "
|
||||
"Use stat_only to get a summary of changed files instead of full patch output."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"ref_a": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="First ref (commit hash, branch name, or tag). Omit for working tree diff.",
|
||||
),
|
||||
"ref_b": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Second ref. When provided with ref_a, shows diff between the two.",
|
||||
),
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Optional file or directory path to restrict the diff to",
|
||||
),
|
||||
"stat_only": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description="Return only a file-change summary (--stat) instead of the full diff",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -60,13 +60,15 @@ def _format_task(t: dict) -> str:
|
||||
# Sync implementations — called via asyncio.to_thread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _task_list(status: str | None) -> str:
|
||||
def _task_list(status: str | None, priority: str | None) -> str:
|
||||
tasks = _load()
|
||||
if status:
|
||||
tasks = [t for t in tasks if t["status"] == status]
|
||||
if priority:
|
||||
tasks = [t for t in tasks if t.get("priority") == priority]
|
||||
if not tasks:
|
||||
label = f"No {status} tasks." if status else "No tasks yet."
|
||||
return label
|
||||
filters = " ".join(f for f in [status, priority] if f)
|
||||
return f"No {filters} tasks." if filters else "No tasks yet."
|
||||
lines = [f"Tasks ({len(tasks)}):\n"]
|
||||
for t in tasks:
|
||||
lines.append(_format_task(t))
|
||||
@@ -118,8 +120,8 @@ def _task_complete(task_id: str) -> str:
|
||||
# Async wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def task_list(status: str | None = None) -> str:
|
||||
return await asyncio.to_thread(_task_list, status)
|
||||
async def task_list(status: str | None = None, priority: str | None = None) -> str:
|
||||
return await asyncio.to_thread(_task_list, status, priority)
|
||||
|
||||
|
||||
async def task_create(title: str, description: str | None = None,
|
||||
@@ -148,6 +150,7 @@ DECLARATIONS = [
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"status": types.Schema(type=types.Type.STRING, description="Filter by status: 'todo', 'in_progress', or 'done'. Omit to list all."),
|
||||
"priority": types.Schema(type=types.Type.STRING, description="Filter by priority: 'low', 'normal', or 'high'. Omit to list all priorities."),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -317,6 +317,149 @@ This pattern maps naturally to several existing concepts:
|
||||
|
||||
---
|
||||
|
||||
## 13. Multi-Level Agent Management
|
||||
|
||||
**Status:** Design complete — implementation not yet started. See `TODO__Agents.md` for the task breakdown.
|
||||
|
||||
Cortex personas can spawn specialized sub-agents to handle parallel or long-running work.
|
||||
Sub-agents can in turn spawn lightweight support agents for simple subtasks. The hierarchy
|
||||
is capped at three levels to prevent runaway delegation.
|
||||
|
||||
### Level Definitions
|
||||
|
||||
| Level | Name | Created by | Can spawn | Tool scope |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Cortex Persona (Inara) | HTTP request / cron | Level 2 | Full orchestrator tool set |
|
||||
| **2** | Specialized Sub-Agent | Level 1 `spawn_agent` | Level 3 only | Role-scoped; `spawn_agent` auto-restricted so children are Level 3 |
|
||||
| **3** | Basic Support Agent | Level 2 `spawn_agent` | Nothing | Narrow tool set; `spawn_agent` and `aider_run` denied |
|
||||
|
||||
**Examples:**
|
||||
- Level 1 spawns a Level 2 **Coder** agent (has file + git + shell tools; can spawn a Level 3 syntax-checker)
|
||||
- Level 1 spawns a Level 2 **Research** agent (web tools only; can spawn a Level 3 web reader for parallel page fetches)
|
||||
- Level 2 spawns a Level 3 **Support** agent for a focused subtask (web_search only, no writes, no further delegation)
|
||||
|
||||
### Core Problem: Everything is Currently Synchronous
|
||||
|
||||
Both `spawn_agent` and `aider_run` block the calling coroutine for their full duration
|
||||
(default 120s / 300s respectively). Level 1 (Inara) cannot respond to the user, send
|
||||
notifications, or inspect other agents while waiting. For 5-minute Aider runs or multi-step
|
||||
research agents this is unusable — the user sees nothing until completion or timeout.
|
||||
|
||||
### Design
|
||||
|
||||
#### 1. Agent Manager (`cortex/agent_manager.py`)
|
||||
|
||||
A lightweight in-process registry of running and recently completed agents. Module-level
|
||||
dict protected by `asyncio.Lock()`:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AgentRecord:
|
||||
agent_id: str # UUID
|
||||
level: int # 1 / 2 / 3
|
||||
role: str # e.g. "coder", "research"
|
||||
task: str # first 200 chars of the task
|
||||
status: str # running / done / failed / cancelled / timeout
|
||||
started: datetime
|
||||
finished: datetime | None
|
||||
parent_id: str | None # lineage — which agent spawned this one
|
||||
result: str | None # populated on completion (first 500 chars)
|
||||
notify: bool # fire web_push/NC Talk notification on completion
|
||||
user: str
|
||||
|
||||
_agents: dict[str, AgentRecord] = {}
|
||||
_lock = asyncio.Lock()
|
||||
```
|
||||
|
||||
On completion, the manager calls `notification.py notify()` if `notify=True` — the same
|
||||
function used by reminder checks and cron completions. Completed agents stay in the
|
||||
registry for 24 hours then are pruned on next access.
|
||||
|
||||
#### 2. Background Mode for `spawn_agent`
|
||||
|
||||
Add `background: bool = False` and `notify: bool = False` to `spawn_agent`. When
|
||||
`background=False` (default): existing synchronous blocking behaviour — unchanged, no
|
||||
regression. When `background=True`: wraps the run in `asyncio.create_task()`, registers
|
||||
in the agent manager, returns an `agent_id` string immediately.
|
||||
|
||||
```python
|
||||
# Level 1 — non-blocking delegation:
|
||||
agent_id = await spawn_agent(
|
||||
task="Research Zigbee mesh repeaters; summarize findings to my journal",
|
||||
role="research",
|
||||
background=True,
|
||||
notify=True, # web_push + NC Talk when done
|
||||
)
|
||||
# Returns "550e8400-..." immediately. Inara continues responding to the user.
|
||||
```
|
||||
|
||||
#### 3. Agent Lifecycle Tools
|
||||
|
||||
Three new tools, wired into `cortex/tools/__init__.py` under the "Agents" category:
|
||||
|
||||
| Tool | Params | Description |
|
||||
|---|---|---|
|
||||
| `agent_status(agent_id)` | `agent_id: str` | Status, role, task, elapsed, result preview |
|
||||
| `agent_list(status=None, limit=10)` | `status: str \| None` | All agents for current user; filter by status |
|
||||
| `agent_cancel(agent_id)` | `agent_id: str` | Cancel a running background agent (admin, confirm-required) |
|
||||
|
||||
Level 1 can call these between tool rounds to check on delegated work without blocking.
|
||||
|
||||
#### 4. Level Enforcement
|
||||
|
||||
`agent_level` is passed through `spawn_agent` calls as a ContextVar so each agent knows
|
||||
where it sits in the hierarchy. Enforcement is automatic and simple:
|
||||
|
||||
- **L1 → spawns L2:** `spawn_agent` called normally. Child agent inherits role tools.
|
||||
- **L2 → spawns L3:** `spawn_agent` automatically adds `deny_tools=["spawn_agent", "aider_run"]`
|
||||
to the child's effective tool set. Level 3 agents cannot further delegate.
|
||||
- **Level 3:** `spawn_agent` and `aider_run` are never in the tool list.
|
||||
|
||||
Level is stored in `AgentRecord.level` — the lineage (`parent_id`) provides a full call tree.
|
||||
|
||||
#### 5. `aider_run` Background Mode
|
||||
|
||||
Add `background: bool = False` and `notify: bool = False` to `aider_run`. When `True`,
|
||||
runs the Aider subprocess via `asyncio.create_task()`, registers in the agent manager,
|
||||
returns `agent_id` immediately. When called in background mode, `aider_run` is removed
|
||||
from `CONFIRM_REQUIRED` — the user is not blocking on a confirmation gate since the call
|
||||
returns instantly.
|
||||
|
||||
```python
|
||||
# Level 1 or 2 — fire and forget a code change:
|
||||
agent_id = await aider_run(
|
||||
project="cortex",
|
||||
task="Add max_chars param to http_fetch in tools/web.py, cap at 32768",
|
||||
background=True,
|
||||
notify=True,
|
||||
)
|
||||
```
|
||||
|
||||
### Implementation Order
|
||||
|
||||
1. **`agent_manager.py`** — AgentRecord + registry CRUD + completion notification hook.
|
||||
Foundation for everything else; ~100 lines.
|
||||
2. **`spawn_agent` background mode** — `background` + `notify` + `agent_level` params;
|
||||
`asyncio.create_task()`; registers in manager. Existing sync path unchanged.
|
||||
3. **`agent_status` / `agent_list` / `agent_cancel`** — wire into `__init__.py`; add to
|
||||
`TOOL_CATEGORIES["Agents"]`, `TOOL_ROLES` (cancel = admin), `CONFIRM_REQUIRED` (cancel).
|
||||
4. **Level enforcement** — `agent_level` ContextVar; auto `deny_tools` at L2→L3 boundary.
|
||||
5. **`aider_run` background mode** — same pattern as step 2.
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `cortex/agent_manager.py` | **New** — AgentRecord, registry dict, start/finish/cancel/list functions |
|
||||
| `cortex/tools/agents.py` | Add `background`, `notify`, `agent_level` to `spawn_agent`; add `agent_status`, `agent_list`, `agent_cancel` functions + declarations |
|
||||
| `cortex/tools/aider.py` | Add `background`, `notify` params; register with agent_manager when background |
|
||||
| `cortex/tools/__init__.py` | Register new agent tools; update TOOL_CATEGORIES, TOOL_ROLES, CONFIRM_REQUIRED |
|
||||
|
||||
See §12 for the existing `allow_tools` / `deny_tools` per-call restrictions that level
|
||||
enforcement builds on.
|
||||
|
||||
---
|
||||
|
||||
## 12. Spawner-Level Tool Restrictions — `spawn_agent` Permission Control
|
||||
|
||||
**Status:** Design complete, not yet built.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cortex / Inara — Master Index
|
||||
# Cortex — Master Index
|
||||
|
||||
> Start here. This document is a map, not a manual.
|
||||
> Last updated: 2026-05-09
|
||||
> Last updated: 2026-06-03
|
||||
>
|
||||
> **Documentation philosophy:** Cortex is a no-black-box system. Docs must match reality.
|
||||
> Update docs before implementing significant changes. Verify they still match after.
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## What It Is
|
||||
|
||||
Cortex is a self-hosted personal AI platform. It routes messages from any input channel to AI backends, manages a resident agent (Inara) with persistent memory, and coordinates across a fleet of machines. It is infrastructure, not a product.
|
||||
Cortex is a self-hosted personal AI platform. It routes messages from any input channel to AI backends, manages per-user AI personas with persistent memory, and coordinates across a fleet of machines. It is infrastructure, not a product.
|
||||
|
||||
**Running at:** `https://cortex.dgrzone.com` | `systemctl --user restart cortex`
|
||||
|
||||
@@ -26,23 +26,25 @@ Cortex is a self-hosted personal AI platform. It routes messages from any input
|
||||
| Claude backend | ✅ Live | Primary — via Claude Code CLI |
|
||||
| Gemini backend | ✅ Live | Fallback — via Gemini CLI |
|
||||
| Local backend | ✅ Live | Open WebUI/Ollama on scott_gaming; per-user multi-model config |
|
||||
| Gemini orchestrator | ✅ Live | Tool loop → Claude response, ⚡ toggle in UI (47 tools) |
|
||||
| Gemini orchestrator | ✅ Live | Tool loop → Claude response, ⚡ toggle in UI (66 tools) |
|
||||
| Local orchestrator | ✅ Live | OpenAI-compatible ReAct loop; used when orchestrator role → local model |
|
||||
| Model registry V2 | ✅ Live | Providers (Anthropic/Google/Local), multi-account Gemini, role assignments |
|
||||
| Memory distillation | ✅ Live | Short (daily) / Mid (weekly) / Long (monthly) |
|
||||
| Multi-user | ✅ Live | Scott, Holly, Brian — each with own personas |
|
||||
| Session search | ✅ Live | Full-text search across past session logs |
|
||||
| Proactive cron | ✅ Live | `message` and `brief` job types → NC Talk / web push |
|
||||
| Proactive cron | ✅ Live | 5 job types: `remind`, `note`, `message`, `brief`, `task` (full orchestrator loop) → NC Talk / web push |
|
||||
| Schedules web UI | ✅ Live | `/settings/crons` — view, add, edit, pause/resume, delete jobs without going through the AI |
|
||||
| Tool audit log | ✅ Live | Every orchestrator tool call logged to `home/{user}/tool_audit/` |
|
||||
| Token usage tracking | ✅ Live | Per-user daily buckets in `home/{user}/usage.json`; visible in Settings |
|
||||
| Web push notifications | ✅ Live | VAPID push; `web_push` orchestrator tool; subscribe via ☰ menu |
|
||||
| Proactive notifications | ✅ Live | Daily reminder check (09:00); distill/cron completion alerts; dedicated `/settings/notifications` page |
|
||||
| Sub-agent spawning | ✅ Live | `spawn_agent` tool — synchronous sub-agents via any configured model |
|
||||
| Sub-agent spawning | ✅ Live | `spawn_agent` tool — sync or background; `agent_status`/`agent_list`/`agent_cancel`; 3-level hierarchy (L2→L3 enforcement built in) |
|
||||
| Aider coding agent | ✅ Live | `aider_run` tool — Aider subprocess; model-agnostic (DeepSeek, Ollama, OpenRouter, etc.) |
|
||||
| Agent private notes | ✅ Live | `AGENT_NOTES.md` — orchestrator-only notepad; 3 rolling backups; user-visible as read-only |
|
||||
| Distill safety | ✅ Live | Per-persona asyncio lock, per-endpoint cooldowns, Rebuild option |
|
||||
| Guided onboarding | ✅ Live | Setup Step 3 for OpenRouter; existing-user banner; settings quick-link |
|
||||
|
||||
**50 orchestrator tools** — `http_post` (URL allowlist POST), `nc_talk_history` (read Talk messages), and local orchestrator retry logic added 2026-05-09.
|
||||
**69 orchestrator tools** across 17 domain modules — added 2026-06-03: `agent_status`/`agent_list` (user-level)/`agent_cancel` (admin, confirm-required); background mode for `spawn_agent` (`background=True` returns agent_id immediately; `notify=True` sends push on completion); `agent_manager.py` registry with lineage tracking and 24h pruning; L2→L3 level enforcement auto-denies `spawn_agent`/`aider_run` in Level 3 children. Added 2026-05-23: `aider_run` (Aider coding agent subprocess; project aliases for cortex/aether_api/aether_frontend/aether_container; model-agnostic via `.aider.conf.yml` or env vars; admin-only, confirm-required). `.aider.conf.yml` added to project root (read-only context, Python lint-cmd, auto-commits). Added 2026-05-12: `file_diff`, `git_status` / `git_log` / `git_diff` (read-only git inspection), `ae_db_query` / `ae_db_describe` / `ae_db_show_view` (SELECT-only Aether MariaDB access, admin, per-user credentials). `/settings/integrations` page added (admin-only). File attachments in chat (images for vision-capable local models; text/code files for all backends). Settings pages unified under `pg.css`. Added 2026-05-13: `task` cron type (full orchestrator loop on a schedule); monthly/yearly schedule formats (`monthly`, `monthly:DD:HH:MM`, `yearly:MM:DD:HH:MM`); Schedules web UI at `/settings/crons` (list, add, edit, pause, delete); HA inbound webhook tools toggle (orchestrator vs. direct LLM); Anthropic API key backend (`anthropic_api` model type via Anthropic SDK — alternative to CLI OAuth); Cloud APIs catalog in Model Registry — named provider picker (OpenRouter, OpenAI, Groq, X.ai/Grok, Together.ai, Fireworks.ai, Custom) with auto-filled URLs; hosts split into Cloud APIs / Local Hosts sections. Added 2026-05-15: Per-user custom roles — three required roles (`chat`, `orchestrator`, `distill`) are always present; users can add/remove custom roles (e.g. `coder`, `research`) via the Model Registry UI; existing `.env`-defined roles auto-migrated. Settings pages (`local_llm.html` + all settings pages) migrated to Tailwind CSS CDN (no build step); `preflight: false` preserves `pg.css` base styles; `input[type=checkbox/radio]` global width fix in `pg.css`; `btn-submit` now responsive (`w-full md:w-96`).
|
||||
|
||||
**Active users / personas:** scott/inara, holly/tina, brian/wintermute
|
||||
|
||||
|
||||
362
documentation/PLAN__Tool_Schema_Optimization.md
Normal file
362
documentation/PLAN__Tool_Schema_Optimization.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# PLAN — Reduce Tool Schema Overhead in Cortex
|
||||
|
||||
**Goal:** Eliminate the per-round, per-message transmission of all 45 tool definitions.
|
||||
Drop overhead from ~8K-10K tokens per round to near zero for casual chat, and to a
|
||||
relevant subset for orchestrated work.
|
||||
|
||||
**Status:** Draft — ready for Claude Code implementation.
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Every orchestrated (⚡ toggled on) message triggers a ReAct tool loop. The full 45-tool
|
||||
schema is rebuilt and transmitted **on every round of every call** — including rounds
|
||||
where no tool is invoked and messages where no tool is needed at all. This wastes
|
||||
thousands of tokens per interaction.
|
||||
|
||||
The architecture already has the building blocks for a fix: role configs support a
|
||||
`tools` allow-list, and `get_openai_tools_for_role()` already accepts filtering
|
||||
parameters. They're just not being wired together effectively.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Role-Based Tool Filtering (Foundation)
|
||||
|
||||
**Effort:** Small. **Impact:** High.
|
||||
|
||||
### What
|
||||
|
||||
Define which tools each role actually needs, then enforce the filtering so roles
|
||||
only receive their relevant tool subset.
|
||||
|
||||
### Implementation
|
||||
|
||||
**1. Audit every role and define tool lists.**
|
||||
|
||||
| Role | Tools needed | Approx count |
|
||||
|------|-------------|-------------|
|
||||
| `chat` | None (zero tools — should never be in the orchestration loop) | 0 |
|
||||
| `orchestrator` | web, file (admin), shell (admin), tasks, cron, reminders, scratchpad, Aether journals, agent notes, system (admin), spawn_agent, HA, ae_db, git, file_diff, file_syntax_check, notifications (admin) | 25-30 |
|
||||
| `distill` | None (pure text processing) | 0 |
|
||||
| `coder` | file (admin), shell (admin), git, file_diff, file_syntax_check | 8-10 |
|
||||
| `research` | web_search, web_read, http_fetch | 3 |
|
||||
| `admin` (role) | All 45 (admin-level access) | 45 |
|
||||
|
||||
**2. Store tool lists per role in `config.yaml` or the model registry defaults.**
|
||||
The role config already has a `tools` field — populate it with the lists above.
|
||||
|
||||
**3. Enforce in `get_openai_tools_for_role()`.**
|
||||
The function is called from `openai_orchestrator.py` around line 451. Currently if
|
||||
`tools` is empty/missing it returns all tools. Change so that:
|
||||
|
||||
- If role config has a `tools` list → return only those tools
|
||||
- If role config has `tools: false` → return empty list
|
||||
- If role config has no `tools` field → return all (backward compat)
|
||||
|
||||
At the call site (`_run_from_messages`), pass the role's tool allow-list into
|
||||
`get_openai_tools_for_role()` via the `tool_list` parameter that already exists.
|
||||
|
||||
### Files to change
|
||||
|
||||
- `cortex/openai_orchestrator.py` — wire role config `tools` into the call to
|
||||
`get_openai_tools_for_role()`
|
||||
- `cortex/model_registry.py` — ensure `get_role_config()` returns the `tools` field
|
||||
(it does already, line 487)
|
||||
- `cortex/config.py` or `home/{user}/model_registry.json` — define the tool lists
|
||||
per default role
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Dynamic Keyword-Based Tool Routing (High Impact)
|
||||
|
||||
**Effort:** Small. **Impact:** Very High.
|
||||
|
||||
### What
|
||||
|
||||
Before entering the ReAct tool loop, scan the user's message with a lightweight
|
||||
keyword classifier to determine which tool categories are relevant. Only include
|
||||
tools from matched categories — typically 3-8 tools instead of 45.
|
||||
|
||||
This is the **core optimization.** For the 80%+ of messages that only need a narrow
|
||||
set of tools (or none at all), this eliminates the bulk of schema overhead on every
|
||||
round.
|
||||
|
||||
### The Hybrid Stack
|
||||
|
||||
```
|
||||
User message
|
||||
↓
|
||||
[1] Role filter (Phase 1) — narrows 45 tools → ~25 for orchestrator role
|
||||
↓
|
||||
[2] Keyword classifier (Phase 2) — narrows ~25 → 3-8 relevant tools
|
||||
↓
|
||||
[3] ReAct loop — only transmitting the relevant subset each round
|
||||
```
|
||||
|
||||
If the keyword classifier matches nothing (e.g. "good morning", "test", "what do you
|
||||
think?"), it returns an empty tool set — effectively routing the message as a pure
|
||||
chat interaction with zero tool overhead.
|
||||
|
||||
### Keyword Category Map
|
||||
|
||||
Each category maps keywords → tool names. Simple regex/contains matching.
|
||||
|
||||
| Category | Trigger keywords | Tools included |
|
||||
|----------|-----------------|---------------|
|
||||
| `web` | search, google, look up, what is, who is, weather, forecast, temperature, news, article, website, find, research | web_search, web_read, http_fetch |
|
||||
| `web_post` | post to, send to, webhook, trigger, notify | http_post |
|
||||
| `file` | read file, show file, open file, list files, directory, grep, find in, search in, diff, compare, syntax check | file_read, file_list, file_write, file_diff, file_grep, file_syntax_check, file_stat |
|
||||
| `git` | git, commit, branch, pushed, pulled, merge, repo, repository | git_status, git_log, git_diff |
|
||||
| `system` | restart, update, status, logs, deploy, shell, command, run, health, is it running | cortex_status, cortex_logs, cortex_restart, cortex_update, shell_exec |
|
||||
| `tasks` | task, todo, to-do, to do, add task, create task, what's on my list, pending | task_list, task_create, task_update, task_complete |
|
||||
| `cron` | schedule, cron, every day, every week, recurring, automate, job | cron_list, cron_add, cron_remove, cron_toggle |
|
||||
| `reminders` | remind, reminder, remember, don't forget | reminders_add, reminders_list, reminders_remove, reminders_clear |
|
||||
| `scratchpad` | scratch, scratchpad, working notes, jot down, notepad | scratch_read, scratch_write, scratch_append, scratch_clear |
|
||||
| `ha` | home assistant, light, thermostat, turn on, turn off, kitchen, bedroom, switch, sensor, temperature | ha_get_state, ha_get_states, ha_call_service |
|
||||
| `aether` | journal, aether, note entry, log entry, search journals, ae_ | ae_journal_list, ae_journal_search, ae_journal_entry_read, ae_journal_entries_list, ae_journal_entry_create, ae_journal_entry_update, ae_journal_entry_disable, ae_journal_entry_append, ae_journal_entry_prepend |
|
||||
| `aether_db` | database, query, sql, select, db, table, schema, maria | ae_db_query, ae_db_describe, ae_db_show_view |
|
||||
| `notifications` | notify, push, send email, email, message, talk, nextcloud | web_push, email_send, nc_talk_send, nc_talk_history |
|
||||
| `agents` | spawn, sub-agent, delegate, agent | spawn_agent |
|
||||
| `notes` | agent notes, private notes, my notes | agent_notes_read, agent_notes_write, agent_notes_append, agent_notes_clear |
|
||||
| `session` | remember, session, history, last time, what did we, earlier, yesterday, last week | session_read, session_search |
|
||||
| `ae_tasks` | ae task, kanban, board | ae_task_list |
|
||||
| `claude` | claude, allow directory, permissions | claude_allow_dir |
|
||||
|
||||
### Implementation
|
||||
|
||||
In `openai_orchestrator.py`, before the ReAct loop starts:
|
||||
|
||||
```python
|
||||
def _classify_tool_categories(user_message: str) -> list[str]:
|
||||
"""Classify a user message into tool categories based on keywords.
|
||||
|
||||
Returns a list of category names whose tools should be included.
|
||||
Returns empty list if no categories match (pure chat).
|
||||
"""
|
||||
message_lower = user_message.lower()
|
||||
|
||||
category_keywords = {
|
||||
"web": ["search", "look up", "what is", "who is", "weather",
|
||||
"forecast", "news", "find on", "google", "website",
|
||||
"article", "research", "temperature"],
|
||||
"web_post": ["post to", "send to", "webhook", "trigger webhook"],
|
||||
"file": ["read file", "show file", "list file", "directory",
|
||||
"grep", "search in", "find in", "diff", "compare",
|
||||
"syntax check", "open file"],
|
||||
"git": ["git", "commit", "branch", "pulled", "merged",
|
||||
"repository", "repo"],
|
||||
"system": ["restart", "update", "status", "logs", "deploy",
|
||||
"run command", "shell", "is it running", "health"],
|
||||
"tasks": ["task", "todo", "to-do", "to do", "add task",
|
||||
"create task", "pending", "what's on my list"],
|
||||
"cron": ["schedule", "cron", "every day", "every week",
|
||||
"recurring", "automate", "job"],
|
||||
"reminders": ["remind", "reminder", "remember", "don't forget"],
|
||||
"scratchpad": ["scratch", "scratchpad", "working note", "jot down",
|
||||
"notepad"],
|
||||
"ha": ["home assistant", "light", "thermostat", "turn on",
|
||||
"turn off", "switch", "sensor", "temperature in",
|
||||
"kitchen", "bedroom", "garage"],
|
||||
"aether": ["journal", "aether journal", "note entry", "log entry",
|
||||
"search journal", "ae_journal"],
|
||||
"aether_db": ["database", "query", "sql", "select", "db", "table",
|
||||
"schema", "maria", "run query"],
|
||||
"notifications":["notify", "push notification", "send email", "email",
|
||||
"talk message", "nextcloud"],
|
||||
"agents": ["spawn", "sub-agent", "delegate", "spawn agent"],
|
||||
"notes": ["agent notes", "private notes", "my notes",
|
||||
"agent_notes"],
|
||||
"session": ["remember", "session", "history", "last time",
|
||||
"what did we", "earlier", "yesterday", "last week",
|
||||
"previously"],
|
||||
"ae_tasks": ["ae task", "kanban", "board", "ae_task"],
|
||||
"claude": ["claude allow", "claude directory"],
|
||||
}
|
||||
|
||||
matched = []
|
||||
for category, keywords in category_keywords.items():
|
||||
if any(kw in message_lower for kw in keywords):
|
||||
matched.append(category)
|
||||
|
||||
return matched
|
||||
```
|
||||
|
||||
Then at the orchestration entry point, after determining the role's base tool list
|
||||
(Phase 1), apply the keyword filter:
|
||||
|
||||
```python
|
||||
# Phase 1: Get role's base tool list
|
||||
role_tools = get_role_config(username, role).get("tools")
|
||||
|
||||
# Phase 2: Dynamically narrow based on message content
|
||||
matched_categories = _classify_tool_categories(user_message)
|
||||
if matched_categories:
|
||||
category_tool_map = { ... } # defined at module level
|
||||
dynamic_tools = []
|
||||
for cat in matched_categories:
|
||||
dynamic_tools.extend(category_tool_map.get(cat, []))
|
||||
# Intersect with role_tools so we never grant more than the role allows
|
||||
if role_tools:
|
||||
dynamic_tools = [t for t in dynamic_tools if t in role_tools]
|
||||
active_tools = get_openai_tools_for_role(
|
||||
role=user_role,
|
||||
tool_list=dynamic_tools or None
|
||||
)
|
||||
else:
|
||||
# No keywords matched — likely causal chat route to /chat
|
||||
# or use empty tool list
|
||||
active_tools = []
|
||||
```
|
||||
|
||||
### Edge Cases to Handle
|
||||
|
||||
1. **Multiple categories match:** Union all matched tool sets. The `for cat in matched_categories` loop handles this naturally.
|
||||
|
||||
2. **No categories match:** Return empty tool set. The orchestrator loop won't start — this effectively becomes a chat message without incurring the schema tax. If the LLM needs tools anyway, it will respond with a natural language request, and the user can rephrase.
|
||||
|
||||
3. **Ambiguous short messages:** "Hey can you check something" — matches nothing, falls through to empty tools. This is correct behavior; the LLM will ask "what do you want me to check?" and the next message will have a clear intent.
|
||||
|
||||
4. **Over-broad keywords:** "search" in "search journals" could trigger both `web` and `aether`. The union handles this — both categories' tools are included, which is what you want.
|
||||
|
||||
### File to change
|
||||
|
||||
- `cortex/openai_orchestrator.py` — add `_classify_tool_categories()` function and
|
||||
wire it into the orchestration entry point before the ReAct loop
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Cache Tool Schema per Session
|
||||
|
||||
**Effort:** Medium. **Impact:** Medium.
|
||||
|
||||
### What
|
||||
|
||||
The tool schema doesn't change between rounds of the same session for a given role.
|
||||
After Phase 2 narrows it to, say, 5 tools, those 5 tool definitions are identical
|
||||
every round. Cache them.
|
||||
|
||||
### Implementation
|
||||
|
||||
Add a session-scoped cache in `openai_orchestrator.py`:
|
||||
|
||||
```python
|
||||
# Module-level cache: key = f"{session_id}:{role}:{sorted_tool_list}"
|
||||
_tool_schema_cache: dict[str, list[dict]] = {}
|
||||
|
||||
def _get_cached_tool_schema(session_id: str, role: str, tool_list: list[str] | None) -> list[dict]:
|
||||
key = f"{session_id}:{role}:{sorted(tool_list) if tool_list else 'all'}"
|
||||
if key in _tool_schema_cache:
|
||||
return _tool_schema_cache[key]
|
||||
schemas = get_openai_tools_for_role(role=role, tool_list=tool_list)
|
||||
_tool_schema_cache[key] = schemas
|
||||
return schemas
|
||||
```
|
||||
|
||||
Invalidation: Cache key includes the tool list, so if the dynamic classifier returns
|
||||
different categories on the next message, it gets a fresh cache entry. No explicit
|
||||
invalidation needed.
|
||||
|
||||
### File to change
|
||||
|
||||
- `cortex/openai_orchestrator.py` — add cache dict and lookup before calling
|
||||
`get_openai_tools_for_role()`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Reduce Default Max Rounds
|
||||
|
||||
**Effort:** Trivial. **Impact:** Low-to-medium.
|
||||
|
||||
### What
|
||||
|
||||
Most requests resolve in 1-3 tool calls. A global cap of 10 means up to 7 wasted
|
||||
schema transmissions on edge cases.
|
||||
|
||||
### Implementation
|
||||
|
||||
1. Make `max_rounds` configurable per model in the model registry (it already exists
|
||||
in some model configs — see `home/brian/model_registry.json` line 42).
|
||||
2. Read it from the model config during orchestration instead of using the global
|
||||
`.env` value.
|
||||
3. Lower the default from 10 to 5.
|
||||
|
||||
### Files to change
|
||||
|
||||
- `cortex/.env` — change `ORCHESTRATOR_MAX_ROUNDS=10` to `=5`
|
||||
- `cortex/openai_orchestrator.py` — read per-model `max_rounds` from `model_cfg`
|
||||
instead of only from settings
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — UI Improvements (Independent)
|
||||
|
||||
**Effort:** Small. **Impact:** Medium (UX).
|
||||
|
||||
### What
|
||||
|
||||
Make the tool mode indicator more obvious so the user can quickly tell whether
|
||||
they're incurring the tool tax.
|
||||
|
||||
### Ideas
|
||||
|
||||
- Change ⚡ color: green when tools are on, gray when off
|
||||
- Swap icon: ⚡ (tools) vs. 💬 (chat only)
|
||||
- Add tooltip: "Tools enabled — all 45 tool schemas sent with each message"
|
||||
- Optional: add a "Quick Question" button that sends to `/chat` directly, bypassing
|
||||
the orchestrator entirely
|
||||
|
||||
### Files to change
|
||||
|
||||
- Svelte UI component — likely `ChatInput.svelte` or the chat mode toggle component
|
||||
|
||||
---
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
1. **Phase 1** (role filtering) — foundation. Defines the base tool set per role.
|
||||
2. **Phase 2** (keyword routing) — **the big one.** Slashes 45 tools → 3-8 for the
|
||||
vast majority of messages. Builds on Phase 1's role filtering.
|
||||
3. **Phase 4** (lower max_rounds) — trivial change, do alongside Phase 2.
|
||||
4. **Phase 3** (schema caching) — more involved, compounds savings from Phase 2.
|
||||
5. **Phase 5** (UI) — independent UX polish, can be done any time.
|
||||
|
||||
### Quick Win Path (Recommended First Session)
|
||||
|
||||
Phases 1 + 2 + 4 can be done in a single Claude Code session. They're all in
|
||||
`openai_orchestrator.py` and `model_registry.py` — the same few files. Estimated
|
||||
effort: 45-60 minutes of coding.
|
||||
|
||||
Phase 3 (caching) is a separate, focused session afterward.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Code Locations (from grep audit 2026-05-15)
|
||||
|
||||
| What | File | Line |
|
||||
|------|------|------|
|
||||
| `get_openai_tools_for_role` definition | `cortex/tools.py` | ~540 |
|
||||
| Call site (decides active_tools) | `cortex/openai_orchestrator.py` | ~449 |
|
||||
| `_run_from_messages()` tool loop | `cortex/openai_orchestrator.py` | ~260 |
|
||||
| Role config tools field | `cortex/model_registry.py` | ~487 |
|
||||
| `get_role_config()` | `cortex/model_registry.py` | ~473 |
|
||||
| `save_role_config()` (tools allow-list) | `cortex/model_registry.py` | ~455 |
|
||||
| Global `ORCHESTRATOR_MAX_ROUNDS` | `cortex/.env` | 35 |
|
||||
| `REQUIRED_ROLES` | `cortex/model_registry.py` | 163 |
|
||||
| `DEFINED_ROLES` config | `cortex/config.py` | 80 |
|
||||
| Per-model `max_rounds` example | `home/brian/model_registry.json` | 42 |
|
||||
|
||||
## Appendix B: Token Savings Estimate
|
||||
|
||||
| Scenario | Before (per round) | After Phase 1 | After Phase 1+2 | After All Phases |
|
||||
|----------|-------------------|--------------|-----------------|-----------------|
|
||||
| "What's the weather?" | ~9K tokens | ~5K (25 tools) | ~600 (3 web tools) | ~600 (cached) |
|
||||
| "Good morning" | ~9K tokens | ~5K (25 tools) | 0 (routed to chat) | 0 |
|
||||
| "Turn off kitchen lights" | ~9K tokens | ~5K (25 tools) | ~600 (3 HA tools) | ~600 (cached) |
|
||||
| "Search journals for X" | ~9K tokens | ~5K (25 tools) | ~2K (10 aether tools) | ~2K (cached) |
|
||||
| "Create a task" | ~9K tokens | ~5K (25 tools) | ~800 (4 task tools) | ~800 (cached) |
|
||||
| "Run a SQL query" | ~9K tokens | ~5K (25 tools) | ~600 (3 db tools) | ~600 (cached) |
|
||||
|
||||
At 3 rounds per request and 50 requests/day, that's roughly **1.3M tokens/day saved**
|
||||
vs. **~13K/day after all optimizations** — a 99% reduction for casual chat, ~90% for
|
||||
most tool-using queries.
|
||||
@@ -48,6 +48,8 @@
|
||||
- ✅ `http_post` — POST to external URLs with per-user URL prefix allowlist; admin-only, confirm-required
|
||||
- ✅ `nc_talk_history` — read recent NC Talk messages; requires nc_username + nc_app_password in channels.json
|
||||
- ✅ Local orchestrator retry — exponential backoff on 429/5xx/connection errors (3 attempts)
|
||||
- ✅ Multi-level agent management — `agent_manager.py` (registry + lifecycle), background `spawn_agent`, `agent_status`/`agent_list`/`agent_cancel` tools, 3-level hierarchy enforcement (see `ARCH__FUTURE.md` §13)
|
||||
- ✅ `aider_run` background mode — background task + push notification on completion; sync path unchanged
|
||||
- [ ] Knowledge import — markdown → AE Journals (import script)
|
||||
- [ ] Dev agent pipeline — specialist agents + supervisor + approval gate
|
||||
- [ ] Gitea webhook integration + Actions CI
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cortex / Inara — Agent Task List
|
||||
# Cortex — Agent Task List
|
||||
|
||||
> Read this file before starting any work on this project.
|
||||
> **Status:** Active development — ongoing.
|
||||
@@ -67,6 +67,59 @@ automatically. Remaining work is quality/reliability parity, not ground-up desig
|
||||
- [x] **`email_send`** — SMTP via email_utils, per-user regex allowlist in `home/{user}/email_allowlist.json`, managed via Settings UI textarea + Files panel raw editor — 2026-04-29
|
||||
- [x] **`web_push`** — VAPID push via pywebpush; subscriptions in `home/{user}/push_subscriptions.json`; "Enable notifications" toggle in ☰ menu; sw.js push+notificationclick handlers — 2026-05-05
|
||||
|
||||
### [Agents] Multi-Level Agent Management
|
||||
|
||||
Design: `documentation/ARCH__FUTURE.md` §13
|
||||
|
||||
Three-level hierarchy: Level 1 = Cortex Persona; Level 2 = Specialized Sub-Agent
|
||||
(can spawn Level 3); Level 3 = Basic Support Agent (cannot spawn). All spawning is
|
||||
currently synchronous and blocking — this makes long-running agents (Aider, research
|
||||
pipelines) unusable without freezing the orchestrator.
|
||||
|
||||
**Phase 1 — Foundation (build first):**
|
||||
- [x] **`cortex/agent_manager.py`** — `AgentRecord` dataclass (agent_id, level, role,
|
||||
task, status, started, parent_id, result, notify, user); module-level registry dict
|
||||
with `asyncio.Lock()`; `register()`, `finish()`, `cancel_agent()`,
|
||||
`list_agents(user, status)` functions; calls `notification.notify()` on completion
|
||||
when `notify=True`; prune records older than 24 hours on next register — 2026-06-03
|
||||
- [x] **Background mode for `spawn_agent`** — added `background: bool = False` and
|
||||
`notify: bool = False` params; when `background=True`, wraps `_run()` in
|
||||
`asyncio.create_task()`, registers in agent_manager, returns agent_id immediately;
|
||||
existing sync path unchanged — 2026-06-03
|
||||
- [x] **`agent_status(agent_id)` tool** — returns status, role, task excerpt, elapsed
|
||||
seconds, result preview (first 300 chars); user-level — 2026-06-03
|
||||
- [x] **`agent_list(status=None, limit=10)` tool** — returns running + recent agents for
|
||||
current user; filter by `status`; user-level — 2026-06-03
|
||||
- [x] **`agent_cancel(agent_id)` tool** — cancels background task via stored
|
||||
`asyncio.Task` reference; admin-only, confirm-required — 2026-06-03
|
||||
|
||||
**Phase 2 — Level enforcement:**
|
||||
- [x] **L2→L3 boundary enforcement** — `spawn_agent` param `_agent_level` (default 2);
|
||||
when `child_level >= 3`, auto-adds `spawn_agent` + `aider_run` to deny_tools so
|
||||
Level 3 children cannot delegate; level stored in AgentRecord — 2026-06-03
|
||||
- [ ] **`_agent_level=1` from main orchestrators** — Gemini and OpenAI orchestrators
|
||||
should pass `_agent_level=1` when calling spawn_agent so the hierarchy is rooted
|
||||
correctly; currently defaults to 2 (children become Level 3, which is safe but
|
||||
means Level 1 cannot spawn Level 2 that itself spawns Level 3)
|
||||
|
||||
**Phase 3 — `aider_run` async:**
|
||||
- [x] **`aider_run` background mode** — added `background: bool = False` and
|
||||
`notify: bool = False` params; runs subprocess via `asyncio.create_task()`, registers
|
||||
in agent_manager, returns agent_id immediately; confirmation still required (correct
|
||||
— user confirms before the tool runs, not during) — 2026-06-03
|
||||
- [x] **Register new tools in `__init__.py`** — `agent_status`, `agent_list`, `agent_cancel`
|
||||
in `TOOL_CATEGORIES["Agents"]`; `agent_cancel` in `TOOL_ROLES` (admin) and
|
||||
`CONFIRM_REQUIRED`; added to `_CALLABLES` and `_ALL_DECLARATIONS` — 2026-06-03
|
||||
|
||||
**Tests:**
|
||||
- [x] **`cortex/tests/test_agent_manager.py`** — 41 tests covering: agent_manager CRUD,
|
||||
prune, notify hook, spawn_agent background mode (returns immediately, completes async,
|
||||
timeout, failure), level enforcement (L1→L2 permits, L2→L3 auto-denies), agent
|
||||
lifecycle tools output, aider_run background mode — 2026-06-03
|
||||
Run: `cd cortex && .venv/bin/python -m pytest tests/test_agent_manager.py -v`
|
||||
|
||||
---
|
||||
|
||||
### [Tools] Orchestrator tool expansions — Round 2
|
||||
Next additions identified 2026-05-08. See `ARCH__FUTURE.md` §2 for design notes.
|
||||
|
||||
@@ -89,6 +142,16 @@ system prompt by `context_loader.py` at all tiers.
|
||||
- Supports `local_openai` and `gemini_api` model types; returns error string for others
|
||||
- Admin-only tool (powerful — can spawn arbitrarily long sub-tasks)
|
||||
- Host UI: "Max parallel" number input in host edit/add forms
|
||||
- [x] **`spawn_agent` per-call tool restrictions** — `allow_tools` and `deny_tools` params — 2026-05-12
|
||||
- `allow_tools: list[str]` — intersected with role ceiling; cannot grant beyond role config
|
||||
- `deny_tools: list[str]` — blocked even when role permits; falls back to `confirm_deny` gate when `tool_list` is None
|
||||
- Both params documented in FunctionDeclaration for orchestrator use
|
||||
- [x] **`file_diff`** — unified diff between two project-scoped files — 2026-05-12
|
||||
- `cortex/tools/files.py` — `diff -u`, 50 KB output cap, project-scoped path resolution
|
||||
- [x] **`git_status` / `git_log` / `git_diff`** — read-only git inspection — 2026-05-12
|
||||
- `cortex/tools/git.py` — new module; all project-scoped, low risk
|
||||
- `git_log(n, path, oneline)` — last N commits with optional path filter
|
||||
- `git_diff(ref_a, ref_b, path, stat_only)` — any ref range; no args = unstaged vs HEAD
|
||||
- [x] **`http_post`** — POST to external URLs — 2026-05-09
|
||||
- Params: `url: str`, `body: str`, `headers: dict | None`, `max_chars: int`
|
||||
- Per-user URL prefix allowlist in `home/{user}/http_allowlist.json` (JSON array of prefixes)
|
||||
@@ -98,7 +161,7 @@ system prompt by `context_loader.py` at all tiers.
|
||||
- Params: `conversation_token: str` (optional, defaults to notification_room), `limit: int = 20`
|
||||
- Returns last N messages with sender + timestamp, chronological order
|
||||
- Admin-only; requires `nc_username` and `nc_app_password` in channels.json under `nextcloud`
|
||||
- [ ] **`task_list` priority filter** — add `priority` param alongside existing `status`
|
||||
- [x] **`task_list` priority filter** — add `priority` param alongside existing `status` — 2026-05-12
|
||||
- [x] **`http_fetch` max_chars** — optional param, default 8192, cap at 32768 — 2026-05-09
|
||||
- [x] **`web_read(url, max_chars=16000)`** — clean article extraction via trafilatura; strips ads/nav/boilerplate, returns markdown — 2026-05-09
|
||||
- [x] **`session_read(date)`** — read a full session log by YYYY-MM-DD date; lists available dates if not found — 2026-05-09
|
||||
@@ -128,34 +191,25 @@ ability to act on HA via the REST API.
|
||||
- [ ] **Richer payload template** — update `rest_command` in HA to include
|
||||
`trigger.to_state.attributes`, `area_name`, and `previous_state` so Inara gets
|
||||
full device context automatically.
|
||||
- [ ] **HA API tools** — add dedicated orchestrator tools in `cortex/tools/homeassistant.py`:
|
||||
- [x] **HA API tools** — `cortex/tools/homeassistant.py` — 2026-05-12
|
||||
- `ha_get_state(entity_id)` — current state + attributes of any entity
|
||||
- `ha_call_service(domain, service, data)` — turn on lights, set HVAC, lock doors, etc.
|
||||
- `ha_get_states(area=None, domain=None)` — list states with optional filter
|
||||
- Auth via Long-Lived Access Token stored in `channels.json` under `homeassistant.token`
|
||||
- HA URL from `channels.json` under `homeassistant.url`
|
||||
- [ ] **Store HA config in channels.json** — add `url` and `token` fields alongside
|
||||
`webhook_id` so tools can reach the HA REST API (`https://ha.dgrzone.com`)
|
||||
- [ ] **`ha_call_service` confirm-required** — destructive actions (locks, alarms) should
|
||||
go through the confirmation gate
|
||||
- [x] **Store HA config in channels.json** — `url`, `token`, `webhook_id` fields under `homeassistant`; managed via `/settings/notifications` — 2026-05-12
|
||||
- [x] **`ha_call_service` confirm-required** — 2026-05-12
|
||||
|
||||
### [UX] Session delete confirmation
|
||||
The session delete button in the sidebar needs a confirmation step before firing — currently
|
||||
it deletes immediately on click with no undo. A simple `confirm()` dialog or an inline
|
||||
"Are you sure? [Delete] [Cancel]" reveal would prevent accidental data loss.
|
||||
- [ ] Add confirm step to session delete button click handler in `app.js`
|
||||
- [ ] Consider: also confirm for message-level delete (Edit/Delete hover controls)
|
||||
- [x] Inline "Delete this session? [Delete] [Cancel]" reveal on `×` click in `app.js` — 2026-05-12
|
||||
- [x] Message-level delete: "confirm delete / cancel" inline in the actions bar — 2026-05-12
|
||||
|
||||
### [UI] File attachments in chat
|
||||
Upload an image or document inline and have it flow into context. Natural workflow
|
||||
("here's this PDF, summarize it"); local backend already supports multimodal via Open WebUI.
|
||||
- [ ] Add attachment button to input area (paperclip icon, hidden file input)
|
||||
- [ ] Client: encode file as base64 or multipart; send alongside message text
|
||||
- [ ] Server: accept file in `POST /chat`; route to appropriate backend
|
||||
- Claude: `content` array with `image` blocks (base64 or URL)
|
||||
- Gemini: `parts` array with `inline_data`
|
||||
- Local (Open WebUI): `content` array with image_url items
|
||||
- [ ] UI: show thumbnail/filename above the sent message
|
||||
### [UI] File attachments in chat ✅ — 2026-05-12
|
||||
Upload an image or document inline and have it flow into context.
|
||||
- [x] Attachment button (paperclip) in input area; hidden file input
|
||||
- [x] Images sent as base64 inline_data (Gemini API) or image blocks (Claude/local)
|
||||
- [x] Text/code files read as UTF-8, injected as fenced code block in message
|
||||
- [x] Thumbnail/filename shown above sent message in UI
|
||||
|
||||
### [Auth] Encrypted sessions
|
||||
Allow users to opt-in to per-session encryption so session logs on disk cannot be
|
||||
@@ -170,8 +224,8 @@ read without the user's key.
|
||||
### [Models] Model Registry V2 — Unified Provider System
|
||||
See `DESIGN__Model_Registry_V2.md` for full design.
|
||||
- [x] **Phase 1** — V2 schema with providers (Anthropic/Google), multi-account Gemini, auto migration, orchestrator uses account API key — 2026-04-27
|
||||
- [ ] **Phase 2** — Cloud provider UI: Anthropic + Google sections in `/settings/models`, account management, model entry creation for cloud models
|
||||
- [ ] **Phase 3** — Unified roles + toggle redesign: standalone role assignments, chat toggle cycles role slots (Primary/Backup 1/Backup 2) showing model label
|
||||
- [x] **Phase 2** — Cloud provider UI: Anthropic + Google sections in `/settings/models`, account management, model entry creation for cloud models — 2026-04-27
|
||||
- [x] **Phase 3** — Unified roles + toggle redesign: chat toggle cycles chat-role slot models (Primary/Backup 1/Backup 2) by label; slot sent in chat/orchestrate payload — 2026-05-12
|
||||
- [ ] **Phase 4** — Polish: Claude API key, OpenRouter as named provider, catalog sync from API
|
||||
|
||||
### [Intelligence] Knowledge consolidation — Phase 1
|
||||
@@ -226,11 +280,28 @@ Every orchestrator tool invocation logged to `home/{user}/tool_audit/YYYY-MM-DD.
|
||||
|
||||
### [Intelligence] Dev agent pipeline
|
||||
See `ARCH__Intelligence_Layer.md`. Full design not yet started.
|
||||
|
||||
`aider_run` (2026-05-23) provides the execution layer — Cortex dispatches to Aider as
|
||||
the coding worker. Aider is model-agnostic (DeepSeek, Ollama, OpenRouter, etc.) and
|
||||
fully scriptable via `--message --yes-always`. This replaces the Claude Code subprocess
|
||||
dependency for coding tasks. Per-project `.aider.conf.yml` holds read-only context files
|
||||
and lint commands; model/key come from env vars (not committed).
|
||||
|
||||
- [x] **`aider_run` tool** — `cortex/tools/aider.py`; project aliases + subprocess with `--message --yes-always`; admin-only, confirm-required, high risk — 2026-05-23
|
||||
- [x] **`aider_run` async/notify** — background=True fires subprocess via asyncio.create_task(), registers in agent_manager, returns agent_id immediately; notify=True sends push/Talk on completion — 2026-06-03
|
||||
- [x] **`.aider.conf.yml`** — project-level Aider config: `read: [CLAUDE.md]`, Python lint-cmd, auto-commits — 2026-05-23
|
||||
- [x] **`aider_run` multi-provider credentials** — `_resolve_credentials()` pulls from
|
||||
all configured hosts: OpenRouter/OpenAI/Groq/etc. → `--api-key slug=key`;
|
||||
local Open WebUI/Ollama → `--openai-api-base + key`; Anthropic from
|
||||
`providers.anthropic.credentials`; `host_label` param for explicit host selection;
|
||||
auto-prefixes model with `openai/` for generic endpoints — 2026-06-03
|
||||
- [x] **`.gitignore`** — added `.aider.chat.history.md`, `.aider.input.history`, `.aider.llm.history` — 2026-05-23
|
||||
- [ ] Specialist agent: frontend (SvelteKit) code changes
|
||||
- [ ] Specialist agent: backend (FastAPI) code changes
|
||||
- [ ] Supervisor agent: diff review, syntax check, test runner
|
||||
- [ ] Gitea webhook integration: trigger on push/PR, report back
|
||||
- [ ] Human approval gate before commit
|
||||
- [ ] `.aider.conf.yml` for aether_api, aether_frontend, aether_container projects
|
||||
|
||||
### [Intelligence] Supervisor agent
|
||||
- Runs `py_compile`, `svelte-check`, unit tests after specialist agent work
|
||||
@@ -484,7 +555,10 @@ other based on resources and specialisation. No central coordinator required.
|
||||
|
||||
### [Tools] Orchestrator tool expansions — Round 3
|
||||
|
||||
- [ ] **`spawn_agent` tool restrictions** — add `allow_tools` and `deny_tools` optional params to `spawn_agent` so the spawning agent can restrict which tools a sub-agent has access to, independent of role config.
|
||||
- Role config remains the authoritative max; spawner provides per-call restriction.
|
||||
- [x] **`spawn_agent` tool restrictions** — `allow_tools` and `deny_tools` per-call params — 2026-05-12
|
||||
- Role config remains the authoritative ceiling; spawner can only restrict further
|
||||
- `allow_tools`: intersected with role tool list; if role list is None, used directly (role gate still applies)
|
||||
- `deny_tools`: removed from tool list; falls back to `confirm_deny` gate when tool list is unrestricted
|
||||
- Design spec: `ARCH__FUTURE.md` §12
|
||||
- Files to touch: `cortex/tools/agents.py` (filtering logic), Gemini `FunctionDeclaration` (new params)
|
||||
- [x] **`file_diff`** — unified diff of two project-scoped files (`diff -u`); low risk, no admin — 2026-05-12
|
||||
- [x] **`git_status` / `git_log` / `git_diff`** — read-only git inspection tools, project-scoped; `git.py` module — 2026-05-12
|
||||
|
||||
Reference in New Issue
Block a user