Compare commits
38 Commits
b9a78819ac
...
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 | ||
|
|
54eef73b74 | ||
|
|
4c3d9a7a65 | ||
|
|
8ab1942514 | ||
|
|
69ec2f667d | ||
|
|
c9c1ca7de6 | ||
|
|
ac06b3bc7b | ||
|
|
fc6600c33e | ||
|
|
ba91de37c5 | ||
|
|
1d361fe809 | ||
|
|
19d6f004ed | ||
|
|
a66c5a7f84 | ||
|
|
85792a7bcf | ||
|
|
0afa135ce9 | ||
|
|
128d8a7c1e | ||
|
|
3a4f518300 | ||
|
|
348ca120c1 | ||
|
|
7b443b40a4 |
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*
|
||||
|
||||
42
CLAUDE.md
42
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
|
||||
@@ -205,7 +209,13 @@ Cortex is a no-black-box system. Docs must match reality — at all times.
|
||||
1. Implement the tool function in `cortex/tools/<domain>.py`
|
||||
- Must be `async def`; use `asyncio.to_thread` for blocking calls
|
||||
- Return a plain string result
|
||||
2. Add a `FunctionDeclaration` and register it in `cortex/tools/__init__.py`
|
||||
2. Add a `FunctionDeclaration` and register it in `cortex/tools/__init__.py`:
|
||||
- Import the callable
|
||||
- Add to `TOOL_CATEGORIES` (pick an existing category or create one)
|
||||
- Add to `_CALLABLES`
|
||||
- Add a `TOOL_RISK` rating (low/medium/high)
|
||||
- Add to `TOOL_ROLES` if admin-only; add to `CONFIRM_REQUIRED` if destructive
|
||||
- Add module to `_ALL_DECLARATIONS`
|
||||
3. Syntax check: `python3 -m py_compile cortex/tools/<domain>.py`
|
||||
4. Restart Cortex
|
||||
|
||||
@@ -250,7 +260,7 @@ clearly asked for a directory to be unblocked.
|
||||
|
||||
---
|
||||
|
||||
## Current State (2026-05-08)
|
||||
## Current State (2026-05-12)
|
||||
|
||||
Cortex is running and stable. All channels are live:
|
||||
|
||||
@@ -266,17 +276,29 @@ 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)
|
||||
|
||||
**47 orchestrator tools:** web_search, http_fetch, web_read,
|
||||
file_read/list/write/session_read/session_search, shell_exec, claude_allow_dir,
|
||||
**69 orchestrator tools** across 17 domain modules:
|
||||
web_search/http_fetch/web_read/http_post,
|
||||
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,
|
||||
ae_journal_list/search/entries_list/entry_read/entry_create/entry_update/entry_disable/entry_append/entry_prepend,
|
||||
ae_task_list, agent_notes_read/write/append/clear, spawn_agent.
|
||||
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, 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`
|
||||
(max_risk threshold + per-tool whitelist/blacklist). Risk policy stored in `home/{user}/tool_policy.json`.
|
||||
|
||||
See `documentation/TODO__Agents.md` for the active task list.
|
||||
See `documentation/ROADMAP.md` for phases and what's next.
|
||||
|
||||
@@ -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))
|
||||
@@ -229,9 +229,14 @@ def get_user_channels(username: str) -> dict:
|
||||
def get_tool_policy(username: str) -> dict:
|
||||
"""Return the parsed tool_policy.json for a user.
|
||||
|
||||
Keys:
|
||||
allow — tools in CONFIRM_REQUIRED that this user has pre-approved (skip gate)
|
||||
deny — tools always blocked for this user regardless of global CONFIRM_REQUIRED
|
||||
Confirmation-gate keys (existing):
|
||||
allow — tools in CONFIRM_REQUIRED that this user has pre-approved (skip gate)
|
||||
deny — tools always blocked for this user regardless of global CONFIRM_REQUIRED
|
||||
|
||||
Risk-policy keys (new):
|
||||
max_risk — auto-include all tools at/below this level ("low"|"medium"|"high")
|
||||
whitelist — force-include specific tools above max_risk
|
||||
blacklist — force-exclude specific tools regardless of max_risk
|
||||
"""
|
||||
path = settings.home_root() / username / "tool_policy.json"
|
||||
try:
|
||||
@@ -240,6 +245,16 @@ def get_tool_policy(username: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def get_risk_policy(username: str) -> tuple[str | None, list[str], list[str]]:
|
||||
"""Return (max_risk, whitelist, blacklist) from the user's tool policy."""
|
||||
policy = get_tool_policy(username)
|
||||
return (
|
||||
policy.get("max_risk") or None,
|
||||
policy.get("whitelist") or [],
|
||||
policy.get("blacklist") or [],
|
||||
)
|
||||
|
||||
|
||||
def save_tool_policy(username: str, data: dict) -> None:
|
||||
path = settings.home_root() / username / "tool_policy.json"
|
||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||
|
||||
@@ -21,6 +21,8 @@ def load_context(
|
||||
include_short: bool = True,
|
||||
role_append: str = "",
|
||||
inject_datetime: bool = True,
|
||||
inject_mode: bool = True,
|
||||
mode: str = "chat",
|
||||
) -> str:
|
||||
"""
|
||||
Build the system-prompt context block for a given tier and memory toggles.
|
||||
@@ -39,10 +41,18 @@ def load_context(
|
||||
inara_dir = persona_path()
|
||||
parts = []
|
||||
|
||||
# ── 0. Current date and time (per-role toggle — injected first so it's prominent) ──
|
||||
# ── 0. System block — date/time and session mode (injected first so it's prominent) ──
|
||||
system_lines = []
|
||||
if inject_datetime:
|
||||
now = datetime.now().astimezone()
|
||||
parts.append(f"--- System ---\nCurrent date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}")
|
||||
system_lines.append(f"Current date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}")
|
||||
if mode == "otr" and inject_mode:
|
||||
system_lines.append(
|
||||
"Current mode: Off The Record — "
|
||||
"this conversation is private and will not be logged or included in memory distillation"
|
||||
)
|
||||
if system_lines:
|
||||
parts.append("--- System ---\n" + "\n".join(system_lines))
|
||||
|
||||
# ── 1. Core identity (always) ──────────────────────────────────
|
||||
for filename in _CORE:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,8 +8,8 @@ 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, files, distill, auth, orchestrator
|
||||
from routers import ui, onboarding, settings, help, auth_google, local_llm, push, audit, usage
|
||||
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, crons
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -30,6 +30,7 @@ app.add_middleware(SessionAuthMiddleware)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(google_chat.router)
|
||||
app.include_router(nextcloud_talk.router)
|
||||
app.include_router(homeassistant.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(distill.router)
|
||||
app.include_router(auth.router)
|
||||
@@ -50,20 +51,23 @@ app.include_router(onboarding.router)
|
||||
|
||||
# Account settings
|
||||
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)
|
||||
|
||||
@@ -423,12 +466,13 @@ def set_role_config(
|
||||
system_append: str,
|
||||
tools: list[str] | None,
|
||||
inject_datetime: bool = True,
|
||||
inject_mode: bool = True,
|
||||
) -> None:
|
||||
"""Save system_append, tools allow-list, and inject_datetime flag for a role.
|
||||
"""Save system_append, tools allow-list, and per-injection flags for a role.
|
||||
|
||||
tools=None clears the allow-list (role uses all accessible tools).
|
||||
inject_datetime=False suppresses the current date/time from the system prompt
|
||||
for this role — useful for pure processing roles (summarizer, classifier, etc.).
|
||||
inject_datetime=False suppresses the date/time header for pure processing roles.
|
||||
inject_mode=False suppresses the session mode (OTR) line for pure processing roles.
|
||||
"""
|
||||
data = _load(username)
|
||||
roles = data.setdefault("roles", {})
|
||||
@@ -436,6 +480,7 @@ def set_role_config(
|
||||
roles[role] = {}
|
||||
roles[role]["system_append"] = system_append.strip()
|
||||
roles[role]["inject_datetime"] = inject_datetime
|
||||
roles[role]["inject_mode"] = inject_mode
|
||||
if tools is None:
|
||||
roles[role].pop("tools", None)
|
||||
else:
|
||||
@@ -445,19 +490,28 @@ def set_role_config(
|
||||
|
||||
def get_role_config(username: str, role: str) -> dict:
|
||||
"""
|
||||
Return supplemental config for a role: system_append, tools, and inject_datetime.
|
||||
Return supplemental config for a role: system_append, tools, and injection flags.
|
||||
|
||||
All keys are optional in the registry — missing means "use defaults":
|
||||
system_append: str — appended to the system prompt for this role
|
||||
tools: list[str] | None — explicit tool allow-list (None = no restriction)
|
||||
inject_datetime: bool — whether to inject current date/time (default True)
|
||||
inject_mode: bool — whether to inject session mode (OTR) line (default True)
|
||||
"""
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@@ -550,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 []
|
||||
|
||||
|
||||
@@ -602,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,
|
||||
@@ -660,7 +782,8 @@ def save_model(username: str, model_id: str | None, host_id: str,
|
||||
label: str, model_name: str, context_k: int = 0,
|
||||
tags: list[str] | None = None,
|
||||
max_rounds: int | None = None,
|
||||
tools: bool = True) -> str:
|
||||
tools: bool = True,
|
||||
reasoning_budget_tokens: int | None = None) -> str:
|
||||
"""Create or update a local_openai model entry. Returns the model ID."""
|
||||
data = _load(username)
|
||||
tags = tags or []
|
||||
@@ -668,29 +791,31 @@ def save_model(username: str, model_id: str | None, host_id: str,
|
||||
if model_id:
|
||||
for m in data["models"]:
|
||||
if m["id"] == model_id:
|
||||
m["host_id"] = host_id
|
||||
m["label"] = label.strip() or model_name.strip()
|
||||
m["model_name"] = model_name.strip()
|
||||
m["context_k"] = context_k
|
||||
m["max_rounds"] = max_rounds
|
||||
m["tools"] = tools
|
||||
m["tags"] = tags
|
||||
m["host_id"] = host_id
|
||||
m["label"] = label.strip() or model_name.strip()
|
||||
m["model_name"] = model_name.strip()
|
||||
m["context_k"] = context_k
|
||||
m["max_rounds"] = max_rounds
|
||||
m["tools"] = tools
|
||||
m["tags"] = tags
|
||||
m["reasoning_budget_tokens"] = reasoning_budget_tokens
|
||||
_save(username, data)
|
||||
return model_id
|
||||
model_id = None
|
||||
|
||||
model_id = secrets.token_hex(4)
|
||||
data["models"].append({
|
||||
"id": model_id,
|
||||
"type": "local_openai",
|
||||
"label": label.strip() or model_name.strip(),
|
||||
"model_name": model_name.strip(),
|
||||
"provider": "local",
|
||||
"host_id": host_id,
|
||||
"context_k": context_k,
|
||||
"max_rounds": max_rounds,
|
||||
"tools": tools,
|
||||
"tags": tags,
|
||||
"id": model_id,
|
||||
"type": "local_openai",
|
||||
"label": label.strip() or model_name.strip(),
|
||||
"model_name": model_name.strip(),
|
||||
"provider": "local",
|
||||
"host_id": host_id,
|
||||
"context_k": context_k,
|
||||
"max_rounds": max_rounds,
|
||||
"tools": tools,
|
||||
"tags": tags,
|
||||
"reasoning_budget_tokens": reasoning_budget_tokens,
|
||||
})
|
||||
_save(username, data)
|
||||
return model_id
|
||||
@@ -709,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 = {
|
||||
@@ -759,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.
|
||||
|
||||
@@ -21,11 +21,11 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
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__)
|
||||
@@ -49,6 +49,9 @@ async def run(
|
||||
tool_list: list[str] | None = None,
|
||||
confirm_allow: set[str] | None = None,
|
||||
confirm_deny: set[str] | None = None,
|
||||
max_risk: str | None = None,
|
||||
risk_whitelist: list[str] | None = None,
|
||||
risk_blacklist: list[str] | None = None,
|
||||
) -> OrchestratorResult:
|
||||
"""
|
||||
Run a tool-enabled task using an OpenAI-compatible API.
|
||||
@@ -73,7 +76,20 @@ async def run(
|
||||
_confirm_deny = frozenset(confirm_deny or ())
|
||||
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
|
||||
|
||||
client, model_name, active_tools = _build_client(model_cfg, user_role, tool_list)
|
||||
# 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, 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)
|
||||
|
||||
sys_content = (system_prompt or "") + _TOOL_INSTRUCTION
|
||||
@@ -98,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,
|
||||
@@ -119,6 +135,7 @@ async def run(
|
||||
response=final_response,
|
||||
tool_calls=tool_call_log,
|
||||
backend="local",
|
||||
backend_label=model_label,
|
||||
gemini_summary=final_response,
|
||||
)
|
||||
|
||||
@@ -191,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)
|
||||
@@ -286,7 +329,10 @@ async def _run_from_messages(
|
||||
if active_tools:
|
||||
call_kwargs["tools"] = active_tools
|
||||
call_kwargs["tool_choice"] = "auto"
|
||||
response = await client.chat.completions.create(**call_kwargs)
|
||||
reasoning_budget = (model_cfg or {}).get("reasoning_budget_tokens")
|
||||
if reasoning_budget:
|
||||
call_kwargs["extra_body"] = {"reasoning": {"budget_tokens": reasoning_budget}}
|
||||
response = await _chat_with_retry(client, **call_kwargs)
|
||||
|
||||
choice = response.choices[0]
|
||||
msg = choice.message
|
||||
@@ -345,7 +391,9 @@ async def _run_from_messages(
|
||||
conf_call: dict = {"model": model_name, "messages": messages, "tool_choice": "none"}
|
||||
if active_tools:
|
||||
conf_call["tools"] = active_tools
|
||||
conf_resp = await client.chat.completions.create(**conf_call)
|
||||
if reasoning_budget:
|
||||
conf_call["extra_body"] = {"reasoning": {"budget_tokens": reasoning_budget}}
|
||||
conf_resp = await _chat_with_retry(client, **conf_call)
|
||||
final_response = conf_resp.choices[0].message.content or (
|
||||
"This action requires your explicit confirmation before it can proceed."
|
||||
)
|
||||
@@ -386,10 +434,37 @@ async def _run_from_messages(
|
||||
return final_response, None
|
||||
|
||||
|
||||
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
_MAX_API_RETRIES = 3
|
||||
|
||||
|
||||
async def _chat_with_retry(client, **kwargs):
|
||||
"""Wrap chat.completions.create with exponential backoff on transient errors."""
|
||||
last_exc: Exception = RuntimeError("No attempts made")
|
||||
for attempt in range(_MAX_API_RETRIES):
|
||||
try:
|
||||
return await client.chat.completions.create(**kwargs)
|
||||
except APIConnectionError as e:
|
||||
last_exc = e
|
||||
logger.warning("OpenAI connection error (attempt %d/%d): %s", attempt + 1, _MAX_API_RETRIES, e)
|
||||
except APIStatusError as e:
|
||||
if e.status_code in _RETRY_STATUSES:
|
||||
last_exc = e
|
||||
logger.warning("OpenAI status %d (attempt %d/%d): %s", e.status_code, attempt + 1, _MAX_API_RETRIES, e)
|
||||
else:
|
||||
raise
|
||||
if attempt < _MAX_API_RETRIES - 1:
|
||||
await asyncio.sleep(2 ** attempt) # 1s, 2s
|
||||
raise last_exc
|
||||
|
||||
|
||||
def _build_client(
|
||||
model_cfg: dict | None,
|
||||
user_role: str = "user",
|
||||
tool_list: list[str] | None = None,
|
||||
max_risk: str | None = None,
|
||||
risk_whitelist: list[str] | None = None,
|
||||
risk_blacklist: list[str] | None = None,
|
||||
) -> tuple:
|
||||
"""Build AsyncOpenAI client and return (client, model_name, active_tools)."""
|
||||
if not model_cfg:
|
||||
@@ -409,7 +484,10 @@ def _build_client(
|
||||
if model_cfg.get("tools") is False:
|
||||
active_tools = []
|
||||
else:
|
||||
active_tools = get_openai_tools_for_role(user_role, tool_list)
|
||||
active_tools = _get_cached_tools(
|
||||
user_role, tool_list,
|
||||
max_risk=max_risk, whitelist=risk_whitelist, blacklist=risk_blacklist,
|
||||
)
|
||||
return client, model_name, active_tools
|
||||
|
||||
|
||||
@@ -418,9 +496,15 @@ async def _execute_tool(
|
||||
arguments_json: str,
|
||||
user_role: str = "user",
|
||||
tool_list: list[str] | None = None,
|
||||
max_risk: str | None = None,
|
||||
risk_whitelist: list[str] | None = None,
|
||||
risk_blacklist: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Parse tool arguments and execute with role-filtered callables."""
|
||||
_, callables = get_tools_for_role(user_role, tool_list)
|
||||
_, callables = get_tools_for_role(
|
||||
user_role, tool_list,
|
||||
max_risk=max_risk, whitelist=risk_whitelist, blacklist=risk_blacklist,
|
||||
)
|
||||
try:
|
||||
args = json.loads(arguments_json)
|
||||
except json.JSONDecodeError:
|
||||
|
||||
@@ -99,6 +99,7 @@ class OrchestratorResult:
|
||||
response: str # final user-facing response (from Claude)
|
||||
tool_calls: list[dict] = field(default_factory=list) # [{tool, args, result}]
|
||||
backend: str = "claude" # model that produced the final response
|
||||
backend_label: str = "" # human-readable model label for display
|
||||
gemini_summary: str = "" # what Gemini handed to Claude (debug/display)
|
||||
checkpoint: OrchestrateCheckpoint | None = None # set when awaiting confirmation
|
||||
|
||||
@@ -116,6 +117,9 @@ async def run(
|
||||
confirm_allow: set[str] | None = None,
|
||||
confirm_deny: set[str] | None = None,
|
||||
max_rounds: int | None = None,
|
||||
max_risk: str | None = None,
|
||||
risk_whitelist: list[str] | None = None,
|
||||
risk_blacklist: list[str] | None = None,
|
||||
) -> OrchestratorResult:
|
||||
"""
|
||||
Run the full orchestration loop for a task.
|
||||
@@ -153,7 +157,10 @@ async def run(
|
||||
contents: list[types.Content] = [
|
||||
types.Content(role="user", parts=[types.Part(text=task_with_context)])
|
||||
]
|
||||
tool_declarations, tool_callables = get_tools_for_role(user_role, tool_list)
|
||||
tool_declarations, tool_callables = get_tools_for_role(
|
||||
user_role, tool_list, max_risk=max_risk,
|
||||
whitelist=risk_whitelist, blacklist=risk_blacklist,
|
||||
)
|
||||
tool_call_log: list[dict] = []
|
||||
|
||||
gemini_summary, checkpoint = await _run_from_contents(
|
||||
@@ -202,7 +209,12 @@ async def resume(checkpoint: OrchestrateCheckpoint, confirmed: bool) -> Orchestr
|
||||
"""Continue a job that was paused at a confirmation gate."""
|
||||
api_key = checkpoint.gemini_api_key or settings.gemini_api_key
|
||||
client = genai.Client(api_key=api_key)
|
||||
tool_declarations, tool_callables = get_tools_for_role(checkpoint.user_role, checkpoint.tool_list)
|
||||
tool_declarations, tool_callables = get_tools_for_role(
|
||||
checkpoint.user_role, checkpoint.tool_list,
|
||||
max_risk=getattr(checkpoint, "max_risk", None),
|
||||
whitelist=getattr(checkpoint, "risk_whitelist", None),
|
||||
blacklist=getattr(checkpoint, "risk_blacklist", None),
|
||||
)
|
||||
|
||||
effective_confirm = (CONFIRM_REQUIRED - set(checkpoint.confirm_allow)) | set(checkpoint.confirm_deny)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ from persona import set_context, validate as validate_persona
|
||||
from auth_utils import COOKIE_NAME, decode_token
|
||||
import model_registry
|
||||
import event_bus
|
||||
from model_registry import get_role_config
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -41,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
|
||||
@@ -53,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):
|
||||
@@ -90,20 +99,39 @@ async def _stream_chat(req: ChatRequest):
|
||||
session_id = req.session_id or generate_session_id()
|
||||
tier = req.tier or settings.default_tier
|
||||
|
||||
role_cfg = get_role_config(user, req.chat_role)
|
||||
system_prompt = load_context(
|
||||
tier,
|
||||
include_long=req.include_long,
|
||||
include_mid=req.include_mid,
|
||||
include_short=req.include_short,
|
||||
inject_datetime=role_cfg.get("inject_datetime", True),
|
||||
inject_mode=role_cfg.get("inject_mode", True),
|
||||
mode="otr" if req.off_record else "chat",
|
||||
)
|
||||
history = load_session(session_id)
|
||||
history.append({"role": "user", "content": req.message})
|
||||
|
||||
# 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(
|
||||
system_prompt=system_prompt,
|
||||
messages=history,
|
||||
model=req.model,
|
||||
role=req.chat_role,
|
||||
slot=req.slot,
|
||||
attachment=llm_attachment,
|
||||
))
|
||||
|
||||
try:
|
||||
@@ -119,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",
|
||||
@@ -127,6 +159,7 @@ async def _stream_chat(req: ChatRequest):
|
||||
"backend": actual_backend,
|
||||
"backend_label": backend_label,
|
||||
"host": host,
|
||||
"off_record": req.off_record,
|
||||
})
|
||||
save_session(session_id, history)
|
||||
if not req.off_record:
|
||||
@@ -197,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.
|
||||
|
||||
@@ -225,10 +277,20 @@ 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
|
||||
|
||||
orch_label = None
|
||||
if username:
|
||||
orch_cfg = model_registry.get_model_for_role(username, "orchestrator")
|
||||
if orch_cfg:
|
||||
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,
|
||||
"fallback": _BACKEND_FALLBACK.get(p, "claude"),
|
||||
|
||||
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)
|
||||
|
||||
199
cortex/routers/homeassistant.py
Normal file
199
cortex/routers/homeassistant.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Home Assistant webhook router — POST /webhook/ha/{username}/{webhook_id}
|
||||
|
||||
Receives event payloads from HA automations and routes them to Inara.
|
||||
Auth: the webhook_id in the URL acts as the shared secret (same model HA uses).
|
||||
Response is delivered async via notify() — NC Talk, web push, etc.
|
||||
|
||||
channels.json schema:
|
||||
"homeassistant": {
|
||||
"webhook_id": "your-secret-id",
|
||||
"persona": "inara",
|
||||
"tier": 2,
|
||||
"role": "chat",
|
||||
"tools": false
|
||||
}
|
||||
|
||||
HA automation example (rest_command):
|
||||
rest_command:
|
||||
cortex_notify:
|
||||
url: "https://cortex.dgrzone.com/webhook/ha/scott/your-secret-id"
|
||||
method: POST
|
||||
content_type: "application/json"
|
||||
payload: '{"message": "{{message}}", "entity_id": "{{entity_id}}", "state": "{{state}}"}'
|
||||
|
||||
automation:
|
||||
trigger:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.front_door
|
||||
to: "on"
|
||||
action:
|
||||
- action: rest_command.cortex_notify
|
||||
data:
|
||||
message: "Front door opened"
|
||||
entity_id: "binary_sensor.front_door"
|
||||
state: "on"
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
|
||||
|
||||
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy, get_risk_policy
|
||||
from context_loader import load_context
|
||||
from llm_client import complete
|
||||
from notification import notify
|
||||
from persona import set_context
|
||||
from session_logger import log_turn
|
||||
from session_store import load as load_session, save as save_session
|
||||
from config import settings
|
||||
import event_bus
|
||||
import model_registry
|
||||
import orchestrator_engine
|
||||
import openai_orchestrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_task(body: dict) -> str:
|
||||
"""Turn an HA event payload into a natural-language prompt for Inara."""
|
||||
if "message" in body:
|
||||
msg = str(body["message"])
|
||||
extras = {k: body[k] for k in ("entity_id", "state", "trigger", "event", "area") if k in body}
|
||||
if extras:
|
||||
msg += "\n\nContext: " + json.dumps(extras)
|
||||
return msg
|
||||
return "Home Assistant event:\n" + json.dumps(body, indent=2)
|
||||
|
||||
|
||||
async def _process_event(username: str, body: dict, cfg: dict) -> None:
|
||||
persona_name = cfg.get("persona", "inara")
|
||||
tier = cfg.get("tier") or settings.default_tier
|
||||
role = cfg.get("role", "chat")
|
||||
use_tools = cfg.get("tools", False)
|
||||
|
||||
set_context(username, persona_name)
|
||||
|
||||
task = _build_task(body)
|
||||
session_id = f"ha_{username}"
|
||||
history = load_session(session_id)
|
||||
session_msgs = list(history)
|
||||
|
||||
logger.info("HA event for %s: %r", username, task[:80])
|
||||
|
||||
backend = "unknown"
|
||||
try:
|
||||
if use_tools:
|
||||
role_cfg = model_registry.get_role_config(username, role)
|
||||
system_prompt = load_context(
|
||||
tier,
|
||||
role_append=role_cfg.get("system_append", ""),
|
||||
inject_datetime=role_cfg.get("inject_datetime", True),
|
||||
inject_mode=role_cfg.get("inject_mode", True),
|
||||
)
|
||||
orch_model = model_registry.get_model_for_role(username, "orchestrator")
|
||||
user_role_val = get_user_role(username)
|
||||
tool_list = role_cfg.get("tools")
|
||||
policy = get_tool_policy(username)
|
||||
c_allow = set(policy.get("allow", []))
|
||||
c_deny = set(policy.get("deny", []))
|
||||
max_risk, risk_wl, risk_bl = get_risk_policy(username)
|
||||
|
||||
if orch_model and orch_model.get("type") == "local_openai":
|
||||
result = await openai_orchestrator.run(
|
||||
task=task,
|
||||
system_prompt=system_prompt,
|
||||
session_messages=session_msgs or None,
|
||||
model_cfg=orch_model,
|
||||
user_role=user_role_val,
|
||||
tool_list=tool_list,
|
||||
confirm_allow=c_allow,
|
||||
confirm_deny=c_deny,
|
||||
max_risk=max_risk,
|
||||
risk_whitelist=risk_wl,
|
||||
risk_blacklist=risk_bl,
|
||||
)
|
||||
else:
|
||||
gemini_key = (
|
||||
(orch_model.get("api_key") if orch_model else None)
|
||||
or get_user_gemini_key(username)
|
||||
)
|
||||
result = await orchestrator_engine.run(
|
||||
task=task,
|
||||
system_prompt=system_prompt,
|
||||
session_messages=session_msgs or None,
|
||||
respond_with_claude=True,
|
||||
gemini_api_key=gemini_key,
|
||||
model_name=orch_model.get("model_name") if orch_model else None,
|
||||
response_role=role,
|
||||
user_role=user_role_val,
|
||||
tool_list=tool_list,
|
||||
confirm_allow=c_allow,
|
||||
confirm_deny=c_deny,
|
||||
max_risk=max_risk,
|
||||
risk_whitelist=risk_wl,
|
||||
risk_blacklist=risk_bl,
|
||||
)
|
||||
response_text = result.response
|
||||
backend = result.backend
|
||||
|
||||
else:
|
||||
system_prompt = load_context(tier)
|
||||
msgs = list(session_msgs) + [{"role": "user", "content": task}]
|
||||
response_text, backend = await complete(system_prompt=system_prompt, messages=msgs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("HA event error for %s: %s", username, e)
|
||||
return
|
||||
|
||||
logger.info("HA response via %s (%d chars)", backend, len(response_text))
|
||||
|
||||
history.append({"role": "user", "content": task})
|
||||
history.append({"role": "assistant", "content": response_text})
|
||||
save_session(session_id, history)
|
||||
log_turn(session_id, task, response_text)
|
||||
|
||||
await event_bus.publish({
|
||||
"type": "ha_event",
|
||||
"session_id": session_id,
|
||||
"response": response_text,
|
||||
"backend": backend,
|
||||
})
|
||||
|
||||
await notify(username, response_text)
|
||||
|
||||
|
||||
@router.post("/webhook/ha/{username}/{webhook_id}")
|
||||
async def ha_webhook(
|
||||
username: str,
|
||||
webhook_id: str,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> Response:
|
||||
"""Receive an event from a Home Assistant automation and route it to Inara."""
|
||||
channels = get_user_channels(username)
|
||||
cfg = channels.get("homeassistant")
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="Channel not configured")
|
||||
|
||||
if webhook_id != cfg.get("webhook_id", ""):
|
||||
logger.warning("HA webhook: bad webhook_id for user %r", username)
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook ID")
|
||||
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||
else:
|
||||
form = await request.form()
|
||||
body = dict(form)
|
||||
|
||||
if not body:
|
||||
return Response(status_code=200)
|
||||
|
||||
background_tasks.add_task(_process_event, username, body, cfg)
|
||||
return Response(status_code=200)
|
||||
@@ -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,15 +260,32 @@ 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">'
|
||||
|
||||
cur_label = m.get("label", "")
|
||||
cur_model_name = m.get("model_name", "")
|
||||
cur_ctx = m.get("context_k", 0) or 0
|
||||
cur_max_rounds = m.get("max_rounds") or 0
|
||||
cur_tools = m.get("tools", True)
|
||||
cur_tags = ", ".join(m.get("tags") or [])
|
||||
cur_label = m.get("label", "")
|
||||
cur_model_name = m.get("model_name", "")
|
||||
cur_ctx = m.get("context_k", 0) or 0
|
||||
cur_max_rounds = m.get("max_rounds") or 0
|
||||
cur_tools = m.get("tools", True)
|
||||
cur_tags = ", ".join(m.get("tags") or [])
|
||||
cur_reasoning_budget = m.get("reasoning_budget_tokens") or 0
|
||||
_rb_levels = [(0, "Off — Non-think"), (1024, "Light"), (4096, "Moderate"), (8192, "High"), (32768, "Max")]
|
||||
reasoning_opts = "".join(
|
||||
f'<option value="{v}" {"selected" if cur_reasoning_budget == v else ""}>{lbl}</option>'
|
||||
for v, lbl in _rb_levels
|
||||
)
|
||||
|
||||
model_rows += f'''
|
||||
<div class="model-row" id="model-{m["id"]}">
|
||||
@@ -256,6 +332,13 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
<input type="number" name="max_rounds" value="{cur_max_rounds}" min="0"
|
||||
title="Per-model tool loop cap. 0 = use the global default (orchestrator_max_rounds).">
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 auto">
|
||||
<label title="Reasoning depth via OpenRouter's reasoning.budget_tokens. Off = Non-think. Light ~1k, Moderate ~4k, High ~8k, Max ~32k tokens.">Reasoning</label>
|
||||
<select name="reasoning_budget_tokens"
|
||||
title="Reasoning depth via OpenRouter's reasoning.budget_tokens. Off = Non-think. Light ~1k, Moderate ~4k, High ~8k, Max ~32k tokens.">
|
||||
{reasoning_opts}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:0 0 auto">
|
||||
<label title="Whether this model supports tool calling. If not supported, requests skip the tool loop entirely.">Tool calling</label>
|
||||
<select name="tools"
|
||||
@@ -293,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}" '
|
||||
@@ -310,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">'
|
||||
@@ -318,13 +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'<span class="rcp-hint" style="display:block;margin-top:0.2rem">'
|
||||
f'Disable for pure processing roles (summarizer, classifier, translator)</span>'
|
||||
f'<label class="rcp-check">'
|
||||
f'<input type="checkbox" class="rcp-mode-cb" data-role="{role}" checked>'
|
||||
f'<span>Inject session mode (Chat / Off The Record) into system prompt</span>'
|
||||
f'</label>'
|
||||
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 '
|
||||
@@ -335,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({
|
||||
@@ -348,35 +457,47 @@ def _render(username: str, success: str = "", error: str = "") -> str:
|
||||
"system_append": roles.get(role, {}).get("system_append", ""),
|
||||
"tools": roles.get(role, {}).get("tools") or None,
|
||||
"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:
|
||||
@@ -391,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)
|
||||
@@ -410,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)
|
||||
@@ -421,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)
|
||||
@@ -438,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)
|
||||
@@ -449,25 +595,26 @@ 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)
|
||||
async def add_model(
|
||||
request: Request,
|
||||
provider: str = Form("local"),
|
||||
label: str = Form(""),
|
||||
context_k: int = Form(0),
|
||||
max_rounds: int = Form(0),
|
||||
tools: int = Form(1),
|
||||
tags: str = Form(""),
|
||||
request: Request,
|
||||
provider: str = Form("local"),
|
||||
label: str = Form(""),
|
||||
context_k: int = Form(0),
|
||||
max_rounds: int = Form(0),
|
||||
tools: int = Form(1),
|
||||
tags: str = Form(""),
|
||||
reasoning_budget_tokens: int = Form(0),
|
||||
# local-only fields
|
||||
host_id: str = Form(""),
|
||||
model_name: str = Form(""),
|
||||
host_id: str = Form(""),
|
||||
model_name: str = Form(""),
|
||||
# cloud-only fields
|
||||
cloud_model_name: str = Form(""),
|
||||
account_id: str = Form(""),
|
||||
credential_id: str = Form("cli"),
|
||||
cloud_model_name: str = Form(""),
|
||||
account_id: str = Form(""),
|
||||
credential_id: str = Form("cli"),
|
||||
):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
@@ -476,21 +623,23 @@ async def add_model(
|
||||
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 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)
|
||||
max_rounds=max_rounds_, tools=tools_bool,
|
||||
reasoning_budget_tokens=reasoning_budget_)
|
||||
display = label or model_name
|
||||
|
||||
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,
|
||||
@@ -500,53 +649,56 @@ 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)
|
||||
async def edit_model(
|
||||
request: Request,
|
||||
model_id: str,
|
||||
mtype: str = Form(""),
|
||||
label: str = Form(""),
|
||||
model_name: str = Form(""),
|
||||
context_k: int = Form(0),
|
||||
max_rounds: int = Form(0),
|
||||
tools: int = Form(1),
|
||||
tags: str = Form(""),
|
||||
host_id: str = Form(""),
|
||||
account_id: str = Form(""),
|
||||
credential_id: str = Form("cli"),
|
||||
request: Request,
|
||||
model_id: str,
|
||||
mtype: str = Form(""),
|
||||
label: str = Form(""),
|
||||
model_name: str = Form(""),
|
||||
context_k: int = Form(0),
|
||||
max_rounds: int = Form(0),
|
||||
tools: int = Form(1),
|
||||
tags: str = Form(""),
|
||||
reasoning_budget_tokens: int = Form(0),
|
||||
host_id: str = Form(""),
|
||||
account_id: str = Form(""),
|
||||
credential_id: str = Form("cli"),
|
||||
):
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
if not model_name.strip():
|
||||
return HTMLResponse(_render(username, 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
|
||||
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)
|
||||
max_rounds=max_rounds_, tools=tools_bool,
|
||||
reasoning_budget_tokens=reasoning_budget_)
|
||||
elif mtype == "gemini_api":
|
||||
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)
|
||||
@@ -555,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")
|
||||
@@ -607,15 +793,19 @@ async def set_role_config(request: Request) -> JSONResponse:
|
||||
system_append = body.get("system_append", "")
|
||||
tools = body.get("tools") # list[str] or None
|
||||
inject_datetime = body.get("inject_datetime", True)
|
||||
inject_mode = body.get("inject_mode", True)
|
||||
|
||||
if not role:
|
||||
return JSONResponse({"error": "role is required"}, status_code=400)
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
return JSONResponse({"error": "tools must be a list or null"}, status_code=400)
|
||||
|
||||
reg.set_role_config(username, role, system_append, tools, inject_datetime=bool(inject_datetime))
|
||||
logger.info("role config saved: %s %s (tools=%s inject_datetime=%s)",
|
||||
username, role, len(tools) if tools is not None else "all", inject_datetime)
|
||||
reg.set_role_config(username, role, system_append, tools,
|
||||
inject_datetime=bool(inject_datetime),
|
||||
inject_mode=bool(inject_mode))
|
||||
logger.info("role config saved: %s %s (tools=%s inject_datetime=%s inject_mode=%s)",
|
||||
username, role, len(tools) if tools is not None else "all",
|
||||
inject_datetime, inject_mode)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
|
||||
|
||||
from auth_utils import get_user_channels
|
||||
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy, get_risk_policy
|
||||
from context_loader import load_context
|
||||
from llm_client import complete
|
||||
from notification import _send_nct_message
|
||||
@@ -13,6 +15,9 @@ from session_logger import log_turn
|
||||
from session_store import load as load_session, save as save_session
|
||||
from config import settings
|
||||
import event_bus
|
||||
import model_registry
|
||||
import orchestrator_engine
|
||||
import openai_orchestrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -50,15 +55,19 @@ async def _process_message(
|
||||
nextcloud_url: str,
|
||||
secret: str,
|
||||
timeout: int,
|
||||
cfg: dict,
|
||||
) -> None:
|
||||
logger.info("NCT process: token=%s user=%s text=%r", conversation_token, actor_name, user_text)
|
||||
|
||||
set_context(username, persona_name)
|
||||
|
||||
session_id = f"nct_{username}_{conversation_token}"
|
||||
system_prompt = load_context(settings.default_tier)
|
||||
history = load_session(session_id)
|
||||
history.append({"role": "user", "content": user_text})
|
||||
tier = cfg.get("tier") or settings.default_tier
|
||||
role = cfg.get("role", "chat")
|
||||
use_tools = cfg.get("tools", False)
|
||||
|
||||
session_id = f"nct_{username}_{conversation_token}"
|
||||
history = load_session(session_id)
|
||||
session_msgs = list(history) # snapshot before we append
|
||||
|
||||
await event_bus.publish({
|
||||
"type": "nct_message",
|
||||
@@ -68,11 +77,76 @@ async def _process_message(
|
||||
"actor": actor_name,
|
||||
})
|
||||
|
||||
backend = "unknown"
|
||||
try:
|
||||
response_text, backend = await asyncio.wait_for(
|
||||
complete(system_prompt=system_prompt, messages=history),
|
||||
timeout=timeout,
|
||||
)
|
||||
if use_tools:
|
||||
await _send_reply(conversation_token, "⏳ Working on it…", nextcloud_url, secret)
|
||||
|
||||
role_cfg = model_registry.get_role_config(username, role)
|
||||
system_prompt = load_context(
|
||||
tier,
|
||||
role_append=role_cfg.get("system_append", ""),
|
||||
inject_datetime=role_cfg.get("inject_datetime", True),
|
||||
inject_mode=role_cfg.get("inject_mode", True),
|
||||
)
|
||||
orch_model = model_registry.get_model_for_role(username, "orchestrator")
|
||||
user_role_val = get_user_role(username)
|
||||
tool_list = role_cfg.get("tools")
|
||||
policy = get_tool_policy(username)
|
||||
c_allow = set(policy.get("allow", []))
|
||||
c_deny = set(policy.get("deny", []))
|
||||
max_risk, risk_wl, risk_bl = get_risk_policy(username)
|
||||
|
||||
if orch_model and orch_model.get("type") == "local_openai":
|
||||
result = await openai_orchestrator.run(
|
||||
task=user_text,
|
||||
system_prompt=system_prompt,
|
||||
session_messages=session_msgs or None,
|
||||
model_cfg=orch_model,
|
||||
user_role=user_role_val,
|
||||
tool_list=tool_list,
|
||||
confirm_allow=c_allow,
|
||||
confirm_deny=c_deny,
|
||||
max_risk=max_risk,
|
||||
risk_whitelist=risk_wl,
|
||||
risk_blacklist=risk_bl,
|
||||
)
|
||||
else:
|
||||
gemini_key = (
|
||||
(orch_model.get("api_key") if orch_model else None)
|
||||
or get_user_gemini_key(username)
|
||||
)
|
||||
result = await orchestrator_engine.run(
|
||||
task=user_text,
|
||||
system_prompt=system_prompt,
|
||||
session_messages=session_msgs or None,
|
||||
respond_with_claude=True,
|
||||
gemini_api_key=gemini_key,
|
||||
model_name=orch_model.get("model_name") if orch_model else None,
|
||||
response_role=role,
|
||||
user_role=user_role_val,
|
||||
tool_list=tool_list,
|
||||
confirm_allow=c_allow,
|
||||
confirm_deny=c_deny,
|
||||
max_risk=max_risk,
|
||||
risk_whitelist=risk_wl,
|
||||
risk_blacklist=risk_bl,
|
||||
)
|
||||
|
||||
response_text = result.response
|
||||
backend = result.backend
|
||||
|
||||
if result.checkpoint:
|
||||
response_text += "\n\n_(This action requires confirmation — use the web UI to approve or deny.)_"
|
||||
|
||||
else:
|
||||
system_prompt = load_context(tier)
|
||||
history_for_llm = list(session_msgs) + [{"role": "user", "content": user_text}]
|
||||
response_text, backend = await asyncio.wait_for(
|
||||
complete(system_prompt=system_prompt, messages=history_for_llm),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("NCT timeout for %s", conversation_token)
|
||||
await _send_reply(conversation_token, "⏳ Still thinking — this is taking longer than usual.", nextcloud_url, secret)
|
||||
@@ -83,6 +157,8 @@ async def _process_message(
|
||||
return
|
||||
|
||||
logger.info("NCT LLM responded via %s (%d chars)", backend, len(response_text))
|
||||
|
||||
history.append({"role": "user", "content": user_text})
|
||||
history.append({"role": "assistant", "content": response_text})
|
||||
save_session(session_id, history)
|
||||
log_turn(session_id, user_text, response_text)
|
||||
@@ -163,6 +239,6 @@ async def nextcloud_talk_webhook(username: str, request: Request, background_tas
|
||||
background_tasks.add_task(
|
||||
_process_message,
|
||||
conversation_token, user_text, actor_name,
|
||||
username, persona_name, nextcloud_url, secret, timeout,
|
||||
username, persona_name, nextcloud_url, secret, timeout, cfg,
|
||||
)
|
||||
return Response(status_code=200)
|
||||
|
||||
@@ -12,13 +12,14 @@ Designed to be triggered from:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth_utils import get_user_gemini_key, get_user_role, get_tool_policy
|
||||
from auth_utils import get_user_gemini_key, get_user_role, get_tool_policy, get_risk_policy
|
||||
from config import settings
|
||||
from context_loader import load_context
|
||||
from persona import set_context, validate as validate_persona
|
||||
@@ -57,6 +58,7 @@ class OrchestrateRequest(BaseModel):
|
||||
user: str = "scott"
|
||||
persona: str = "inara"
|
||||
chat_role: str = "chat" # role used for the final response (decoupled from tool-loop model)
|
||||
off_record: bool = False # skip session log; inject OTR mode line into system prompt
|
||||
|
||||
|
||||
class OrchestrateResponse(BaseModel):
|
||||
@@ -74,6 +76,8 @@ class JobStatusResponse(BaseModel):
|
||||
response: str | None = None
|
||||
tool_calls: list[dict] | None = None
|
||||
backend: str | None = None
|
||||
backend_label: str | None = None
|
||||
host: str | None = None
|
||||
gemini_summary: str | None = None
|
||||
error: str | None = None
|
||||
pending_confirmation: dict | None = None # {tools: [{name, args}], message: str}
|
||||
@@ -109,6 +113,7 @@ async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse:
|
||||
"error": None,
|
||||
"pending_confirmation": None,
|
||||
"_user": user,
|
||||
"_off_record": req.off_record,
|
||||
}
|
||||
|
||||
async with _jobs_lock:
|
||||
@@ -204,6 +209,8 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
|
||||
include_short=req.include_short,
|
||||
role_append=role_cfg.get("system_append", ""),
|
||||
inject_datetime=role_cfg.get("inject_datetime", True),
|
||||
inject_mode=role_cfg.get("inject_mode", True),
|
||||
mode="otr" if req.off_record else "chat",
|
||||
)
|
||||
|
||||
session_id = req.session_id or generate_session_id()
|
||||
@@ -217,6 +224,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
|
||||
policy = get_tool_policy(user)
|
||||
confirm_allow = set(policy.get("allow", []))
|
||||
confirm_deny = set(policy.get("deny", []))
|
||||
max_risk, risk_wl, risk_bl = get_risk_policy(user)
|
||||
|
||||
if orch_model and orch_model.get("type") == "local_openai":
|
||||
result = await openai_orchestrator.run(
|
||||
@@ -229,6 +237,9 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
|
||||
tool_list=tool_list,
|
||||
confirm_allow=confirm_allow,
|
||||
confirm_deny=confirm_deny,
|
||||
max_risk=max_risk,
|
||||
risk_whitelist=risk_wl,
|
||||
risk_blacklist=risk_bl,
|
||||
)
|
||||
else:
|
||||
gemini_key = (
|
||||
@@ -248,6 +259,9 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
|
||||
confirm_allow=confirm_allow,
|
||||
confirm_deny=confirm_deny,
|
||||
max_rounds=orch_model.get("max_rounds") if orch_model else None,
|
||||
max_risk=max_risk,
|
||||
risk_whitelist=risk_wl,
|
||||
risk_blacklist=risk_bl,
|
||||
)
|
||||
|
||||
if result.checkpoint:
|
||||
@@ -270,7 +284,7 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
|
||||
job_id, len(result.checkpoint.pending_tools))
|
||||
return
|
||||
|
||||
await _finalize_job(job_id, result, session_id, req.task, history)
|
||||
await _finalize_job(job_id, result, session_id, req.task, history, off_record=req.off_record)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Orchestrator job failed: %s", job_id)
|
||||
@@ -316,12 +330,13 @@ async def _resume_job(
|
||||
return
|
||||
|
||||
async with _jobs_lock:
|
||||
session_id = _jobs[job_id].get("session_id") or ""
|
||||
task = _jobs[job_id].get("task", "")
|
||||
session_id = _jobs[job_id].get("session_id") or ""
|
||||
task = _jobs[job_id].get("task", "")
|
||||
off_record = _jobs[job_id].get("_off_record", False)
|
||||
|
||||
from session_store import load as load_session
|
||||
history = load_session(session_id) if session_id else []
|
||||
await _finalize_job(job_id, result, session_id, task, history)
|
||||
await _finalize_job(job_id, result, session_id, task, history, off_record=off_record)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Orchestrator resume failed: %s", job_id)
|
||||
@@ -340,6 +355,7 @@ async def _finalize_job(
|
||||
session_id: str,
|
||||
task: str,
|
||||
history: list,
|
||||
off_record: bool = False,
|
||||
) -> None:
|
||||
"""Save session, log the turn, and mark the job complete."""
|
||||
from session_store import save as save_session, generate_session_id
|
||||
@@ -348,10 +364,19 @@ async def _finalize_job(
|
||||
if not session_id:
|
||||
session_id = generate_session_id()
|
||||
|
||||
history.append({"role": "user", "content": task})
|
||||
history.append({"role": "assistant", "content": result.response})
|
||||
host = platform.node()
|
||||
history.append({"role": "user", "content": task, "off_record": off_record})
|
||||
history.append({
|
||||
"role": "assistant",
|
||||
"content": result.response,
|
||||
"backend": result.backend,
|
||||
"backend_label": result.backend_label,
|
||||
"host": host,
|
||||
"off_record": off_record,
|
||||
})
|
||||
save_session(session_id, history)
|
||||
log_turn(session_id, task, result.response)
|
||||
if not off_record:
|
||||
log_turn(session_id, task, result.response)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
async with _jobs_lock:
|
||||
@@ -362,6 +387,8 @@ async def _finalize_job(
|
||||
"response": result.response,
|
||||
"tool_calls": result.tool_calls,
|
||||
"backend": result.backend,
|
||||
"backend_label": result.backend_label,
|
||||
"host": host,
|
||||
"gemini_summary": result.gemini_summary,
|
||||
})
|
||||
logger.info("Orchestrator job complete: %s (%d tool calls)", job_id, len(result.tool_calls))
|
||||
|
||||
@@ -18,8 +18,7 @@ import jwt
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password, _read_auth, _write_auth, get_user_channels, get_tool_policy, save_tool_policy
|
||||
from tools import CONFIRM_REQUIRED
|
||||
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password, _read_auth, _write_auth, get_user_channels
|
||||
from persona import list_user_personas
|
||||
from config import settings as app_settings
|
||||
|
||||
@@ -54,19 +53,47 @@ 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)
|
||||
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
|
||||
notify_email = _html.escape(channels.get("notification_email", "") or "")
|
||||
nc_room = _html.escape((channels.get("nextcloud") or {}).get("notification_room", "") or "")
|
||||
gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "")
|
||||
channels = get_user_channels(username)
|
||||
nct = channels.get("nextcloud") or {}
|
||||
|
||||
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
|
||||
notify_email = _html.escape(channels.get("notification_email", "") or "")
|
||||
nc_url = _html.escape(nct.get("url", "") or "")
|
||||
nc_bot_secret = _html.escape(nct.get("bot_secret", "") or "")
|
||||
nc_room = _html.escape(nct.get("notification_room", "") or "")
|
||||
nc_username = _html.escape(nct.get("nc_username", "") or "")
|
||||
nc_app_password = _html.escape(nct.get("nc_app_password", "") or "")
|
||||
gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "")
|
||||
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)
|
||||
html = html.replace("{{ nc_url }}", nc_url)
|
||||
html = html.replace("{{ nc_bot_secret }}", nc_bot_secret)
|
||||
html = html.replace("{{ nc_notify_room }}", nc_room)
|
||||
html = html.replace("{{ nc_username }}", nc_username)
|
||||
html = html.replace("{{ nc_app_password }}", nc_app_password)
|
||||
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:
|
||||
@@ -94,14 +121,13 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
|
||||
allowlist_text = ""
|
||||
html = html.replace("{{ email_allowlist }}", allowlist_text)
|
||||
|
||||
# Tool permission policy
|
||||
policy = get_tool_policy(username)
|
||||
tool_allow_text = _html.escape("\n".join(policy.get("allow", [])))
|
||||
tool_deny_text = _html.escape("\n".join(policy.get("deny", [])))
|
||||
confirm_tools_list = _html.escape(", ".join(sorted(CONFIRM_REQUIRED)))
|
||||
html = html.replace("{{ tool_allow }}", tool_allow_text)
|
||||
html = html.replace("{{ tool_deny }}", tool_deny_text)
|
||||
html = html.replace("{{ confirm_required_tools }}", confirm_tools_list)
|
||||
http_al_path = app_settings.home_root() / username / "http_allowlist.json"
|
||||
try:
|
||||
http_prefixes = json.loads(http_al_path.read_text())
|
||||
http_allowlist_text = _html.escape("\n".join(str(p) for p in http_prefixes if str(p).strip()))
|
||||
except Exception:
|
||||
http_allowlist_text = ""
|
||||
html = html.replace("{{ http_allowlist }}", http_allowlist_text)
|
||||
|
||||
persona_items = "\n".join(
|
||||
f'''<li>
|
||||
@@ -112,8 +138,8 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
|
||||
<input type="hidden" name="old_name" value="{p}">
|
||||
<input type="text" name="new_name" value="{p}"
|
||||
pattern="[a-z_][a-z0-9_\\-]{{0,31}}" required>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" class="persona-rename-cancel">Cancel</button>
|
||||
<button type="submit" class="btn-save">Save</button>
|
||||
<button type="button" class="btn-cancel persona-rename-cancel">Cancel</button>
|
||||
</form>
|
||||
</li>''' for p in personas
|
||||
)
|
||||
@@ -122,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:
|
||||
@@ -284,8 +329,16 @@ async def save_notifications(
|
||||
request: Request,
|
||||
notification_channel: str = Form(""),
|
||||
notification_email: str = Form(""),
|
||||
nc_url: str = Form(""),
|
||||
nc_bot_secret: str = Form(""),
|
||||
nc_notification_room: str = Form(""),
|
||||
nc_username: str = Form(""),
|
||||
nc_app_password: str = Form(""),
|
||||
gc_outbound_webhook: str = Form(""),
|
||||
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:
|
||||
@@ -313,42 +366,43 @@ async def save_notifications(
|
||||
else:
|
||||
channels.pop("notification_email", None)
|
||||
|
||||
# NC Talk notification room — nested under "nextcloud"
|
||||
# Nextcloud Talk — full config nested under "nextcloud"
|
||||
if "nextcloud" not in channels:
|
||||
channels["nextcloud"] = {}
|
||||
channels["nextcloud"]["notification_room"] = nc_notification_room.strip()
|
||||
nct = channels["nextcloud"]
|
||||
if nc_url.strip():
|
||||
nct["url"] = nc_url.strip().rstrip("/")
|
||||
# Only overwrite secrets if a new value was provided (blank = keep existing)
|
||||
if nc_bot_secret.strip():
|
||||
nct["bot_secret"] = nc_bot_secret.strip()
|
||||
nct["notification_room"] = nc_notification_room.strip()
|
||||
if nc_username.strip():
|
||||
nct["nc_username"] = nc_username.strip()
|
||||
if nc_app_password.strip():
|
||||
nct["nc_app_password"] = nc_app_password.strip()
|
||||
|
||||
# Google Chat outbound webhook — nested under "google_chat"
|
||||
if "google_chat" not in channels:
|
||||
channels["google_chat"] = {}
|
||||
channels["google_chat"]["outbound_webhook"] = gc_outbound_webhook.strip()
|
||||
|
||||
# Home Assistant — nested under "homeassistant"
|
||||
if "homeassistant" not in channels:
|
||||
channels["homeassistant"] = {}
|
||||
ha = channels["homeassistant"]
|
||||
if ha_url.strip():
|
||||
ha["url"] = ha_url.strip().rstrip("/")
|
||||
if ha_token.strip():
|
||||
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")
|
||||
return HTMLResponse(_notifications_page(username, back_persona, success="Notification settings saved."))
|
||||
|
||||
|
||||
@router.post("/settings/tool-policy", include_in_schema=False)
|
||||
async def save_tool_policy_route(
|
||||
request: Request,
|
||||
allow_list: str = Form(""),
|
||||
deny_list: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
personas = list_user_personas(username)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
|
||||
allow_tools = [ln.strip() for ln in allow_list.splitlines() if ln.strip()]
|
||||
deny_tools = [ln.strip() for ln in deny_list.splitlines() if ln.strip()]
|
||||
save_tool_policy(username, {"allow": allow_tools, "deny": deny_tools})
|
||||
logger.info("tool policy updated for %s (allow=%d deny=%d)", username, len(allow_tools), len(deny_tools))
|
||||
return HTMLResponse(_settings_page(username, personas, back_persona,
|
||||
success="Tool permission policy saved."))
|
||||
|
||||
|
||||
@router.post("/settings/email-allowlist", include_in_schema=False)
|
||||
async def save_email_allowlist(
|
||||
request: Request,
|
||||
@@ -365,3 +419,81 @@ async def save_email_allowlist(
|
||||
path.write_text(json.dumps(lines, indent=2))
|
||||
logger.info("email allowlist updated for %s (%d patterns)", username, len(lines))
|
||||
return HTMLResponse(_settings_page(username, personas, back_persona, success=f"Email allowlist saved ({len(lines)} pattern{'s' if len(lines) != 1 else ''})."))
|
||||
|
||||
|
||||
@router.post("/settings/http-allowlist", include_in_schema=False)
|
||||
async def save_http_allowlist(
|
||||
request: Request,
|
||||
prefixes: str = Form(""),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
personas = list_user_personas(username)
|
||||
back_persona = _preferred_persona(request, username)
|
||||
lines = [ln.strip() for ln in prefixes.splitlines() if ln.strip()]
|
||||
path = app_settings.home_root() / username / "http_allowlist.json"
|
||||
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."))
|
||||
|
||||
193
cortex/routers/tools_settings.py
Normal file
193
cortex/routers/tools_settings.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Tool settings router.
|
||||
|
||||
Routes:
|
||||
GET /settings/tools → tool risk policy page
|
||||
POST /settings/tools → save max_risk + per-tool overrides
|
||||
"""
|
||||
|
||||
import html as _html
|
||||
import json
|
||||
import logging
|
||||
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, get_tool_policy, save_tool_policy, _read_auth
|
||||
from persona import list_user_personas
|
||||
from tools import TOOL_CATEGORIES, TOOL_RISK, CONFIRM_REQUIRED
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_STATIC = Path(__file__).parent.parent / "static"
|
||||
_LAST_PERSONA_COOKIE = "cx_last_persona"
|
||||
|
||||
|
||||
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, "")
|
||||
return cookie_val if cookie_val in names else (names[0] if names else "")
|
||||
|
||||
|
||||
def _build_tool_table(policy: dict) -> str:
|
||||
"""Generate the per-tool override table rows grouped by category."""
|
||||
whitelist = set(policy.get("whitelist") or [])
|
||||
blacklist = set(policy.get("blacklist") or [])
|
||||
|
||||
rows: list[str] = []
|
||||
for category, tools in TOOL_CATEGORIES.items():
|
||||
# Category header spanning all columns
|
||||
escaped_cat = _html.escape(category)
|
||||
rows.append(f'<tr class="tool-cat-row"><td colspan="4">{escaped_cat}</td></tr>')
|
||||
for tool in tools:
|
||||
risk = TOOL_RISK.get(tool, "medium")
|
||||
risk_cls = f"risk-{risk}"
|
||||
risk_html = f'<span class="risk {risk_cls}">{_html.escape(risk)}</span>'
|
||||
|
||||
# Override select value
|
||||
if tool in whitelist:
|
||||
override_val = "whitelist"
|
||||
elif tool in blacklist:
|
||||
override_val = "blacklist"
|
||||
else:
|
||||
override_val = "default"
|
||||
|
||||
def _opt(val: str, label: str) -> str:
|
||||
sel = 'selected' if override_val == val else ''
|
||||
return f'<option value="{val}" {sel}>{label}</option>'
|
||||
|
||||
override_sel = (
|
||||
f'<select name="override_{_html.escape(tool)}" '
|
||||
f'class="override-sel" data-tool="{_html.escape(tool)}">'
|
||||
+ _opt("default", "Default (auto)")
|
||||
+ _opt("whitelist", "Force include")
|
||||
+ _opt("blacklist", "Force exclude")
|
||||
+ '</select>'
|
||||
)
|
||||
|
||||
rows.append(
|
||||
f'<tr data-tool-risk="{_html.escape(risk)}">'
|
||||
f'<td class="tool-name">{_html.escape(tool)}</td>'
|
||||
f'<td>{risk_html}</td>'
|
||||
f'<td><span class="auto-pill"></span></td>'
|
||||
f'<td>{override_sel}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
table_body = "\n".join(rows)
|
||||
return (
|
||||
'<table class="tool-table">'
|
||||
'<thead><tr>'
|
||||
'<th>Tool</th><th>Risk</th><th>Auto status</th><th>Override</th>'
|
||||
'</tr></thead>'
|
||||
f'<tbody>{table_body}</tbody>'
|
||||
'</table>'
|
||||
)
|
||||
|
||||
|
||||
def _tools_page(
|
||||
username: str,
|
||||
back_persona: str = "",
|
||||
success: str = "",
|
||||
error: str = "",
|
||||
) -> str:
|
||||
html = (_STATIC / "tools_settings.html").read_text()
|
||||
policy = get_tool_policy(username)
|
||||
max_risk = policy.get("max_risk") or ""
|
||||
|
||||
# Max risk select options
|
||||
html = html.replace("{{ sel_none }}", "selected" if max_risk == "" else "")
|
||||
html = html.replace("{{ sel_low }}", "selected" if max_risk == "low" else "")
|
||||
html = html.replace("{{ sel_medium }}", "selected" if max_risk == "medium" else "")
|
||||
html = html.replace("{{ sel_high }}", "selected" if max_risk == "high" else "")
|
||||
|
||||
html = html.replace("{{ tool_table_html }}", _build_tool_table(policy))
|
||||
html = html.replace("{{ tool_risk_json }}", json.dumps(TOOL_RISK))
|
||||
html = html.replace("{{ confirm_required_tools }}", _html.escape(", ".join(sorted(CONFIRM_REQUIRED))))
|
||||
html = html.replace("{{ tool_allow }}", _html.escape("\n".join(policy.get("allow") or [])))
|
||||
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>')
|
||||
if error:
|
||||
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
|
||||
return html
|
||||
|
||||
|
||||
@router.get("/settings/tools", include_in_schema=False)
|
||||
async def tools_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(_tools_page(username, back_persona))
|
||||
|
||||
|
||||
@router.post("/settings/tools", include_in_schema=False)
|
||||
async def save_tools(request: Request):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
back_persona = _preferred_persona(request, username)
|
||||
form = await request.form()
|
||||
|
||||
max_risk = (form.get("max_risk") or "").strip()
|
||||
if max_risk not in ("", "low", "medium", "high"):
|
||||
max_risk = ""
|
||||
|
||||
whitelist: list[str] = []
|
||||
blacklist: list[str] = []
|
||||
|
||||
all_tools = [t for tools in TOOL_CATEGORIES.values() for t in tools]
|
||||
for tool in all_tools:
|
||||
val = (form.get(f"override_{tool}") or "").strip()
|
||||
if val == "whitelist":
|
||||
whitelist.append(tool)
|
||||
elif val == "blacklist":
|
||||
blacklist.append(tool)
|
||||
|
||||
allow_tools = [ln.strip() for ln in (form.get("allow_list") or "").splitlines() if ln.strip()]
|
||||
deny_tools = [ln.strip() for ln in (form.get("deny_list") or "").splitlines() if ln.strip()]
|
||||
|
||||
policy = get_tool_policy(username)
|
||||
if max_risk:
|
||||
policy["max_risk"] = max_risk
|
||||
else:
|
||||
policy.pop("max_risk", None)
|
||||
|
||||
policy["whitelist"] = whitelist
|
||||
policy["blacklist"] = blacklist
|
||||
policy["allow"] = allow_tools
|
||||
policy["deny"] = deny_tools
|
||||
|
||||
save_tool_policy(username, policy)
|
||||
logger.info(
|
||||
"tool policy saved for %s: max_risk=%s whitelist=%d blacklist=%d allow=%d deny=%d",
|
||||
username, max_risk or "none", len(whitelist), len(blacklist), len(allow_tools), len(deny_tools),
|
||||
)
|
||||
return HTMLResponse(_tools_page(
|
||||
username, back_persona,
|
||||
success=f"Tool policy saved — max risk: {max_risk or 'none'}, "
|
||||
f"{len(whitelist)} whitelisted, {len(blacklist)} blacklisted.",
|
||||
))
|
||||
@@ -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,24 +82,33 @@ Orchestrated sessions persist to history exactly like regular chat.
|
||||
|
||||
### Available Tools
|
||||
|
||||
47 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` |
|
||||
| **Files** | `file_read`, `file_list`, `file_write`, `session_read`, `session_search` |
|
||||
| **Web** | `web_search`, `http_fetch`, `web_read`, `http_post` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **Notifications** | `web_push`, `email_send`, `nc_talk_send` |
|
||||
| **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 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
|
||||
|
||||
@@ -145,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.
|
||||
|
||||
@@ -176,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.) |
|
||||
@@ -227,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**
|
||||
@@ -256,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.
|
||||
|
||||
@@ -264,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).
|
||||
|
||||
@@ -284,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.
|
||||
@@ -295,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.
|
||||
|
||||
---
|
||||
@@ -335,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -383,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 |
|
||||
@@ -445,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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
? { icon: 'zap', label: 'Run' }
|
||||
: sd;
|
||||
sendBtn.innerHTML = icon_html(effectiveSd.icon) + ' ' + effectiveSd.label;
|
||||
updateSendBtnTitle();
|
||||
|
||||
render_icons();
|
||||
updateInputPlaceholder();
|
||||
@@ -312,9 +313,11 @@
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
|
||||
function updateToolsToggleUI() {
|
||||
tools_toggle_el.classList.toggle('local-on', toolsEnabled);
|
||||
@@ -331,6 +334,64 @@
|
||||
updateToolsToggleUI();
|
||||
});
|
||||
|
||||
function updateSendBtnTitle() {
|
||||
const entry = activeChatModel();
|
||||
const rmodel = entry?.label || '(server default)';
|
||||
const mode = current_mode === 'otr' ? 'Off The Record'
|
||||
: current_mode === 'note' ? 'Note'
|
||||
: 'Chat';
|
||||
const useOrch = toolsEnabled && current_mode !== 'note';
|
||||
|
||||
let lines;
|
||||
if (useOrch) {
|
||||
const omodel = orchestratorModel || '(server default)';
|
||||
lines = [
|
||||
`Model: ${rmodel}`,
|
||||
`Orchestrator: ${omodel} (tool loop)`,
|
||||
`Mode: ${mode}`,
|
||||
];
|
||||
} else {
|
||||
lines = [
|
||||
`Model: ${rmodel}`,
|
||||
`Mode: ${mode}`,
|
||||
`Engine: Direct (no tool loop)`,
|
||||
];
|
||||
}
|
||||
sendBtn.title = lines.join('\n');
|
||||
}
|
||||
|
||||
function startRunTimer() {
|
||||
_runStart = Date.now();
|
||||
function tick() {
|
||||
const secs = Math.floor((Date.now() - _runStart) / 1000);
|
||||
const entry = activeChatModel();
|
||||
const useOrch = toolsEnabled && current_mode !== 'note';
|
||||
const model = useOrch
|
||||
? (orchestratorModel || '(server default)') + ' (tool loop)'
|
||||
: (entry?.label || '(server default)');
|
||||
stopBtn.title = `Running: Chat · ${model}\nElapsed: ${secs}s — click to cancel`;
|
||||
}
|
||||
tick();
|
||||
_runTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
function stopRunTimer() {
|
||||
clearInterval(_runTimer);
|
||||
_runTimer = null;
|
||||
stopBtn.title = '';
|
||||
updateSendBtnTitle();
|
||||
}
|
||||
|
||||
function setProcessing(state) {
|
||||
if (state) {
|
||||
headerEmoji.classList.add('processing');
|
||||
document.body.classList.add('processing');
|
||||
} else {
|
||||
headerEmoji.classList.remove('processing');
|
||||
document.body.classList.remove('processing');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings dropdown ─────────────────────────────────────────
|
||||
settings_btn_el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -406,22 +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 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';
|
||||
@@ -429,17 +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 => {
|
||||
availableRoles = d.available_roles || [];
|
||||
roleIdx = 0;
|
||||
setRoleToggleUI(availableRoles[0] || null);
|
||||
chatModels = d.chat_models || [];
|
||||
availableRoles = d.available_roles || [];
|
||||
orchestratorModel = d.orchestrator_model || null;
|
||||
modelIdx = 0;
|
||||
setModelToggleUI(chatModels[0] || null);
|
||||
_maybeShowNoBanner(availableRoles);
|
||||
});
|
||||
|
||||
@@ -461,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 ───────────────────────────────────────────
|
||||
@@ -627,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);
|
||||
@@ -686,13 +869,11 @@
|
||||
currentHistory.push({ role, content: msg.content });
|
||||
const msgDiv = addMessage(role, msg.content);
|
||||
attachHistoryControls(msgDiv, i);
|
||||
if (role === 'assistant' && (msg.backend_label || msg.backend)) {
|
||||
const modelTag = document.createElement('div');
|
||||
modelTag.className = 'model-tag';
|
||||
const label = msg.backend_label || msg.backend;
|
||||
modelTag.textContent = msg.host ? `${label} · ${msg.host}` : label;
|
||||
msgDiv.appendChild(modelTag);
|
||||
}
|
||||
setMessageMeta(msgDiv, {
|
||||
label: (role === 'assistant') ? (msg.backend_label || msg.backend || '') : '',
|
||||
host: msg.host || '',
|
||||
otr: !!msg.off_record,
|
||||
});
|
||||
}
|
||||
|
||||
if (!silent) addMessage('system', `Resumed session: ${displayName}`);
|
||||
@@ -703,6 +884,37 @@
|
||||
persist_session();
|
||||
}
|
||||
|
||||
// ── Message metadata (hover bar) ─────────────────────────────
|
||||
function setMessageMeta(msgDiv, {label = '', host = '', fallback = false, otr = false} = {}) {
|
||||
const wrapper = msgDiv.closest ? msgDiv.closest('.msg-wrapper') : msgDiv.parentElement;
|
||||
if (!wrapper) return;
|
||||
const actionsDiv = wrapper.querySelector('.msg-actions');
|
||||
if (!actionsDiv) return;
|
||||
|
||||
const existing = actionsDiv.querySelector('.msg-meta');
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!label && !otr) return;
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'msg-meta';
|
||||
|
||||
if (label) {
|
||||
const modelSpan = document.createElement('span');
|
||||
modelSpan.className = 'msg-meta-model' + (fallback ? ' fallback' : '');
|
||||
modelSpan.textContent = (fallback ? '⚡ ' : '') + label + (host ? ' · ' + host : '');
|
||||
meta.appendChild(modelSpan);
|
||||
}
|
||||
if (otr) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'msg-meta-badge otr';
|
||||
badge.textContent = 'OTR';
|
||||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
actionsDiv.insertBefore(meta, actionsDiv.firstChild);
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '?';
|
||||
const mins = Math.floor((Date.now() - new Date(iso)) / 60000);
|
||||
@@ -806,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);
|
||||
@@ -1063,7 +1288,7 @@
|
||||
// ── Chat fetch + SSE handler ─────────────────────────────────
|
||||
// Extracted so the retry button can call it without re-adding the
|
||||
// user message to the DOM or currentHistory.
|
||||
async function _doSend(payload, thinkingDiv) {
|
||||
async function _doSend(payload, thinkingDiv, wasNewSession = false) {
|
||||
try {
|
||||
const res = await fetch('/chat', {
|
||||
method: 'POST',
|
||||
@@ -1115,15 +1340,12 @@
|
||||
currentHistory.push({ role: 'assistant', content: data.response });
|
||||
attachHistoryControls(thinkingDiv, assistHistIdx);
|
||||
|
||||
// Model tag — always shown, amber if fallback was used
|
||||
const modelTag = document.createElement('div');
|
||||
modelTag.className = 'model-tag' + (data.fallback_used ? ' fallback' : '');
|
||||
const label = data.backend_label || data.backend || '';
|
||||
const hostSuffix = data.host ? ` · ${data.host}` : '';
|
||||
modelTag.textContent = data.fallback_used
|
||||
? `⚡ fallback → ${label}${hostSuffix}`
|
||||
: `${label}${hostSuffix}`;
|
||||
thinkingDiv.appendChild(modelTag);
|
||||
setMessageMeta(thinkingDiv, {
|
||||
label: data.backend_label || data.backend || '',
|
||||
host: data.host || '',
|
||||
fallback: !!data.fallback_used,
|
||||
otr: current_mode === 'otr',
|
||||
});
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
@@ -1156,14 +1378,16 @@
|
||||
activeController = new AbortController();
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'flex';
|
||||
headerEmoji.classList.add('processing');
|
||||
setProcessing(true);
|
||||
startRunTimer();
|
||||
|
||||
await _doSend(payload, thinkingDiv);
|
||||
await _doSend(payload, thinkingDiv, false);
|
||||
|
||||
activeController = null;
|
||||
headerEmoji.classList.remove('processing');
|
||||
setProcessing(false);
|
||||
sendBtn.style.display = 'block';
|
||||
stopBtn.style.display = 'none';
|
||||
stopRunTimer();
|
||||
inputEl.focus();
|
||||
});
|
||||
thinkingDiv.appendChild(retryBtn);
|
||||
@@ -1172,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;
|
||||
|
||||
@@ -1181,43 +1405,53 @@
|
||||
syncHeight();
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'flex';
|
||||
headerEmoji.classList.add('processing');
|
||||
setProcessing(true);
|
||||
startRunTimer();
|
||||
|
||||
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();
|
||||
|
||||
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: current_mode === 'otr',
|
||||
chat_role: activeRole()?.role || 'chat',
|
||||
off_record: isOtr,
|
||||
chat_role: 'chat',
|
||||
slot: activeChatModel()?.slot || null,
|
||||
user: CORTEX_USER,
|
||||
persona: CORTEX_PERSONA,
|
||||
...(payloadAttachment ? { attachment: payloadAttachment } : {}),
|
||||
};
|
||||
|
||||
await _doSend(payload, thinkingDiv);
|
||||
await _doSend(payload, thinkingDiv, wasNewSession);
|
||||
|
||||
activeController = null;
|
||||
headerEmoji.classList.remove('processing');
|
||||
setProcessing(false);
|
||||
sendBtn.style.display = 'block';
|
||||
stopBtn.style.display = 'none';
|
||||
stopRunTimer();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
// Extracted so the retry button can call it without re-adding the
|
||||
// user message to the DOM or currentHistory.
|
||||
async function _doOrchestrate(text, thinkingDiv, userMsgDiv) {
|
||||
const submitOtr = current_mode === 'otr';
|
||||
try {
|
||||
const res = await fetch('/orchestrate', {
|
||||
method: 'POST',
|
||||
@@ -1229,7 +1463,9 @@
|
||||
include_long: memLong,
|
||||
include_mid: memMid,
|
||||
include_short: memShort,
|
||||
chat_role: activeRole()?.role || 'chat',
|
||||
off_record: current_mode === 'otr',
|
||||
chat_role: 'chat',
|
||||
slot: activeChatModel()?.slot || null,
|
||||
user: CORTEX_USER,
|
||||
persona: CORTEX_PERSONA,
|
||||
}),
|
||||
@@ -1312,6 +1548,12 @@
|
||||
const assistHistIdx = currentHistory.length;
|
||||
currentHistory.push({ role: 'assistant', content: job.response || '' });
|
||||
attachHistoryControls(thinkingDiv, assistHistIdx);
|
||||
setMessageMeta(thinkingDiv, {
|
||||
label: job.backend_label || job.backend || '',
|
||||
host: job.host || '',
|
||||
otr: submitOtr,
|
||||
});
|
||||
if (submitOtr) setMessageMeta(userMsgDiv, {otr: true});
|
||||
|
||||
renderToolCalls(job.tool_calls, thinkingDiv.parentElement);
|
||||
|
||||
@@ -1340,14 +1582,16 @@
|
||||
activeController = new AbortController();
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'flex';
|
||||
headerEmoji.classList.add('processing');
|
||||
setProcessing(true);
|
||||
startRunTimer();
|
||||
|
||||
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
|
||||
|
||||
activeController = null;
|
||||
headerEmoji.classList.remove('processing');
|
||||
setProcessing(false);
|
||||
sendBtn.style.display = 'block';
|
||||
stopBtn.style.display = 'none';
|
||||
stopRunTimer();
|
||||
inputEl.focus();
|
||||
});
|
||||
thinkingDiv.appendChild(retryBtn);
|
||||
@@ -1356,29 +1600,34 @@
|
||||
}
|
||||
|
||||
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();
|
||||
sendBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'flex';
|
||||
headerEmoji.classList.add('processing');
|
||||
setProcessing(true);
|
||||
startRunTimer();
|
||||
|
||||
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;
|
||||
headerEmoji.classList.remove('processing');
|
||||
setProcessing(false);
|
||||
sendBtn.style.display = 'block';
|
||||
stopBtn.style.display = 'none';
|
||||
stopRunTimer();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
@@ -2114,4 +2363,4 @@
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
}
|
||||
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,84 +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>
|
||||
:root {
|
||||
--pg-bg: #0f1117; --pg-surface: #1a1d27;
|
||||
--pg-border: #2d3148;
|
||||
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
|
||||
--pg-dim: #64748b; --pg-dimmer: #475569;
|
||||
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
|
||||
--pg-accent: #a78bfa;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
|
||||
--pg-border: #d0c8e8;
|
||||
--pg-text: #1a1228; --pg-muted: #5a5478;
|
||||
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
|
||||
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
|
||||
--pg-accent: #7c3aed;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--pg-bg);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-weight: 450;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--pg-text);
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.page { max-width: 720px; margin: 0 auto; }
|
||||
|
||||
/* ── Page nav ── */
|
||||
.page-nav {
|
||||
display: flex; align-items: center; gap: 0.25rem;
|
||||
margin-bottom: 1.75rem; flex-wrap: wrap;
|
||||
}
|
||||
.nav-link {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0.3rem 0.6rem; border-radius: 6px;
|
||||
font-size: 0.8rem; font-weight: 500; color: var(--pg-dim);
|
||||
text-decoration: none; transition: color 0.15s, background 0.15s; white-space: nowrap;
|
||||
}
|
||||
.nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
|
||||
.nav-link.active { color: var(--pg-accent); }
|
||||
.nav-spacer { flex: 1; min-width: 0.5rem; }
|
||||
.nav-link.nav-logout { color: var(--pg-dimmer); }
|
||||
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
|
||||
|
||||
/* ── 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 {
|
||||
@@ -129,36 +85,56 @@
|
||||
.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>
|
||||
<div class="page">
|
||||
<nav class="page-nav" id="page-nav">
|
||||
<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>
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
<nav class="page-nav" id="page-nav">
|
||||
<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>
|
||||
|
||||
<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';
|
||||
@@ -222,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');
|
||||
@@ -247,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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<div class="persona-switcher" id="persona-switcher">
|
||||
<div class="name" id="persona-name">Inara</div>
|
||||
<div class="subtitle">Cortex · Local</div>
|
||||
<div id="session-id"></div>
|
||||
<div class="persona-dropdown" id="persona-dropdown"></div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +165,6 @@
|
||||
</div>
|
||||
|
||||
<div id="messages"></div>
|
||||
<div id="session-id"></div>
|
||||
|
||||
<div id="input-area">
|
||||
<!-- Mode select — compact dropdown, opens upward, MRU sorted -->
|
||||
@@ -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,184 +7,75 @@
|
||||
<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>
|
||||
:root {
|
||||
--pg-bg: #0f1117; --pg-surface: #1a1d27;
|
||||
--pg-border: #2d3148;
|
||||
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
|
||||
--pg-dim: #64748b; --pg-dimmer: #475569;
|
||||
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
|
||||
--pg-border: #d0c8e8;
|
||||
--pg-text: #1a1228; --pg-muted: #5a5478;
|
||||
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
|
||||
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
|
||||
/* ── 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);
|
||||
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); }
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--pg-bg);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-weight: 450;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--pg-text);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--pg-surface);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--pg-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
|
||||
.nav-link.active { color: #a78bfa; }
|
||||
.nav-spacer { flex: 1; min-width: 0.5rem; }
|
||||
.nav-link.nav-logout { color: var(--pg-dimmer); }
|
||||
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
|
||||
|
||||
.logo { margin-bottom: 1.75rem; }
|
||||
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
||||
.logo p { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.2rem; }
|
||||
|
||||
h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--pg-muted);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
}
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--pg-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--pg-bg);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 6px;
|
||||
color: var(--pg-text);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus { border-color: #7c3aed; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
margin-top: 0.25rem;
|
||||
background: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button[type="submit"]:hover { background: #6d28d9; }
|
||||
|
||||
.error { color: #f87171; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
|
||||
.success { color: #4ade80; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
|
||||
|
||||
.btn-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
||||
.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;
|
||||
}
|
||||
.btn:hover { border-color: #7c3aed; color: #a78bfa; }
|
||||
.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); }
|
||||
.hint { font-size: 0.78rem; color: var(--pg-dim); margin-top: 0.35rem; line-height: 1.5; }
|
||||
/* ── Test result feedback (JS-toggled display) ── */
|
||||
#test-result { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<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/notifications" class="nav-link active">Notifications</a>
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
|
||||
<div class="logo">
|
||||
<h1>Notifications</h1>
|
||||
<p>How Inara reaches out proactively — reminders, cron jobs, and memory digests.</p>
|
||||
</div>
|
||||
<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 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 your persona reaches out proactively — reminders, cron jobs, and memory digests.</p>
|
||||
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<!-- Channel config -->
|
||||
<div class="section">
|
||||
<h2>Channel</h2>
|
||||
<form method="POST" action="/settings/notifications">
|
||||
<form method="POST" action="/settings/notifications">
|
||||
|
||||
<!-- Channel selector -->
|
||||
<div class="section">
|
||||
<h2>Channel</h2>
|
||||
<div class="field">
|
||||
<label for="notification_channel">Notification channel</label>
|
||||
<label for="notification_channel">Default outbound channel</label>
|
||||
<select id="notification_channel" name="notification_channel"
|
||||
data-value="{{ notify_channel }}">
|
||||
<option value="">None (disabled)</option>
|
||||
@@ -193,46 +84,205 @@
|
||||
<option value="nextcloud">Nextcloud Talk</option>
|
||||
<option value="google_chat">Google Chat</option>
|
||||
</select>
|
||||
<p class="hint">Used for reminder alerts, distillation summaries, and cron job notifications.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="notification_email">Email 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 }}"
|
||||
placeholder="Leave blank to use login email"
|
||||
placeholder="Leave blank to use your login email"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_notification_room">Nextcloud Talk room token</label>
|
||||
<input type="text" id="nc_notification_room" name="nc_notification_room"
|
||||
value="{{ nc_notify_room }}"
|
||||
placeholder="Token from the Talk room URL"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gc_outbound_webhook">Google Chat webhook URL</label>
|
||||
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
|
||||
value="{{ gc_webhook }}"
|
||||
placeholder="https://chat.googleapis.com/v1/spaces/…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<button type="submit">Save notification settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud Talk -->
|
||||
<div class="section">
|
||||
<h2>Nextcloud Talk</h2>
|
||||
<p class="section-note">
|
||||
Configure to send and receive messages via your Nextcloud Talk bot.
|
||||
<strong>Sending</strong> requires the bot URL, secret, and notification room.
|
||||
<strong>Reading history</strong> (<code>nc_talk_history</code> tool) additionally
|
||||
requires a Nextcloud username and app password.
|
||||
</p>
|
||||
|
||||
<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" class="text-pg-accent">setup guide</a> for step-by-step instructions.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="nc_url">Nextcloud URL</label>
|
||||
<input type="url" id="nc_url" name="nc_url"
|
||||
value="{{ nc_url }}"
|
||||
placeholder="https://cloud.example.com"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_bot_secret">Bot secret</label>
|
||||
<input type="password" id="nc_bot_secret" name="nc_bot_secret"
|
||||
value="{{ nc_bot_secret }}"
|
||||
placeholder="Leave blank to keep existing value"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
<p class="hint">Generated when you registered the bot in Nextcloud Talk.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_notification_room">Notification room token</label>
|
||||
<input type="text" id="nc_notification_room" name="nc_notification_room"
|
||||
value="{{ nc_notify_room }}"
|
||||
placeholder="Token from the Talk room URL"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<p class="hint">The token at the end of the Talk room URL — e.g. <code>abc123def</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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>
|
||||
<div class="field">
|
||||
<label for="nc_username">Nextcloud username</label>
|
||||
<input type="text" id="nc_username" name="nc_username"
|
||||
value="{{ nc_username }}"
|
||||
placeholder="Your Nextcloud login username"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nc_app_password">App password</label>
|
||||
<input type="password" id="nc_app_password" name="nc_app_password"
|
||||
value="{{ nc_app_password }}"
|
||||
placeholder="Leave blank to keep existing value"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Home Assistant -->
|
||||
<div class="section">
|
||||
<h2>Home Assistant</h2>
|
||||
<p class="section-note">
|
||||
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 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>
|
||||
<div class="field">
|
||||
<label for="ha_url">Home Assistant URL</label>
|
||||
<input type="url" id="ha_url" name="ha_url"
|
||||
value="{{ ha_url }}"
|
||||
placeholder="https://ha.yourdomain.com"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ha_token">Long-Lived Access Token</label>
|
||||
<input type="password" id="ha_token" name="ha_token"
|
||||
value=""
|
||||
placeholder="Leave blank to keep existing token"
|
||||
autocomplete="new-password" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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>
|
||||
<div class="field">
|
||||
<label for="ha_webhook_id">Webhook ID</label>
|
||||
<input type="text" id="ha_webhook_id" name="ha_webhook_id"
|
||||
value="{{ ha_webhook_id }}"
|
||||
placeholder="Paste or generate a random secret"
|
||||
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>
|
||||
|
||||
<!-- Google Chat -->
|
||||
<div class="section">
|
||||
<h2>Google Chat</h2>
|
||||
<p class="section-note">
|
||||
Outbound webhook for proactive messages to a Google Chat space.
|
||||
Incoming messages are handled separately via the Google Chat Add-on.
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<label for="gc_outbound_webhook">Webhook URL</label>
|
||||
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
|
||||
value="{{ gc_webhook }}"
|
||||
placeholder="https://chat.googleapis.com/v1/spaces/…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save notification settings</button>
|
||||
</form>
|
||||
|
||||
<!-- Test -->
|
||||
<div class="section">
|
||||
<div class="section" style="margin-top:2rem;">
|
||||
<h2>Test</h2>
|
||||
<p class="hint" style="margin-bottom:0.85rem">
|
||||
<p class="section-note">
|
||||
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="btn-row">
|
||||
<button class="btn" id="btn-test-notify">Send Test Notification</button>
|
||||
<button class="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>
|
||||
|
||||
@@ -253,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';
|
||||
}
|
||||
|
||||
|
||||
189
cortex/static/pg.css
Normal file
189
cortex/static/pg.css
Normal file
@@ -0,0 +1,189 @@
|
||||
/* ─── Cortex settings pages — shared stylesheet ───────────────────────────── */
|
||||
|
||||
/* ── Variables ── */
|
||||
:root {
|
||||
--pg-bg: #0f1117; --pg-surface: #1a1d27; --pg-border: #2d3148;
|
||||
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
|
||||
--pg-dim: #64748b; --pg-dimmer: #475569;
|
||||
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
|
||||
--pg-accent: #a78bfa; /* heading/highlight purple */
|
||||
--pg-action: #7c3aed; /* button/focus purple */
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--pg-bg: #f4f2fa; --pg-surface: #ffffff; --pg-border: #d0c8e8;
|
||||
--pg-text: #1a1228; --pg-muted: #5a5478;
|
||||
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
|
||||
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
|
||||
--pg-accent: #7c3aed;
|
||||
--pg-action: #6d28d9;
|
||||
}
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ── Base ── */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--pg-bg);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-weight: 450;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--pg-text);
|
||||
}
|
||||
|
||||
/* ── Top nav ── */
|
||||
.page-nav {
|
||||
display: flex; align-items: center; gap: 0.25rem;
|
||||
padding: 0.5rem 1rem; background: var(--pg-surface);
|
||||
border-bottom: 1px solid var(--pg-border); flex-wrap: wrap;
|
||||
}
|
||||
.nav-link {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0.3rem 0.6rem; border-radius: 6px;
|
||||
font-size: 0.8rem; font-weight: 500; color: var(--pg-dim);
|
||||
text-decoration: none; transition: color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
|
||||
.nav-link.active { color: var(--pg-accent); }
|
||||
.nav-spacer { flex: 1; min-width: 0.5rem; }
|
||||
.nav-link.nav-logout { color: var(--pg-dimmer); }
|
||||
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
|
||||
|
||||
/* ── Page container ── */
|
||||
.page-wrap {
|
||||
max-width: 860px; margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem; width: 100%;
|
||||
}
|
||||
|
||||
/* ── Page heading ── */
|
||||
.page-title {
|
||||
font-size: 1.4rem; font-weight: 700; color: var(--pg-accent);
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 0.8rem; color: var(--pg-muted);
|
||||
margin-top: 0.2rem; margin-bottom: 1.75rem; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Sections (settings-style, bottom-bordered h2) ── */
|
||||
.section { margin-bottom: 2rem; }
|
||||
.section > h2 {
|
||||
font-size: 0.9rem; font-weight: 600; color: var(--pg-muted);
|
||||
margin-bottom: 1rem; padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
}
|
||||
|
||||
/* ── Form elements ── */
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
label {
|
||||
display: block; font-size: 0.8rem; font-weight: 500;
|
||||
color: var(--pg-muted); margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%; padding: 0.65rem 0.85rem;
|
||||
background: var(--pg-bg); border: 1px solid var(--pg-border);
|
||||
border-radius: 6px; color: var(--pg-text); font-size: 0.95rem;
|
||||
font-family: inherit; outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
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;
|
||||
font-size: 0.88rem; line-height: 1.55; resize: vertical;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
|
||||
/* Primary form submit */
|
||||
.btn-submit {
|
||||
padding: 0.6rem 1.5rem; margin-top: 0.25rem;
|
||||
background: var(--pg-action); border: none; border-radius: 6px;
|
||||
color: #fff; font-size: 0.9rem; font-weight: 600;
|
||||
cursor: pointer; transition: opacity 0.15s;
|
||||
}
|
||||
.btn-submit:hover { opacity: 0.88; }
|
||||
|
||||
/* Compact inline primary (e.g. rename save, inline forms) */
|
||||
.btn-save {
|
||||
padding: 0.4rem 0.9rem; background: var(--pg-action); border: none;
|
||||
border-radius: 6px; color: #fff; font-size: 0.9rem;
|
||||
font-weight: 600; cursor: pointer; transition: opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover { opacity: 0.88; }
|
||||
|
||||
/* Outline secondary (e.g. clear cache, cancel, test actions) */
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem; background: none;
|
||||
border: 1px solid var(--pg-border); border-radius: 6px;
|
||||
color: var(--pg-muted); font-size: 0.88rem; font-weight: 500;
|
||||
cursor: pointer; transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--pg-muted); color: var(--pg-text); }
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Inline cancel */
|
||||
.btn-cancel {
|
||||
padding: 0.4rem 0.75rem; background: none;
|
||||
border: 1px solid var(--pg-border); border-radius: 6px;
|
||||
color: var(--pg-muted); font-size: 0.9rem;
|
||||
cursor: pointer; transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-cancel:hover { border-color: var(--pg-muted); color: var(--pg-text); }
|
||||
|
||||
/* Button-styled link (purple, used for "Settings →" style CTAs) */
|
||||
.action-link {
|
||||
display: inline-block; padding: 0.5rem 1rem;
|
||||
background: var(--pg-action); border-radius: 6px;
|
||||
color: #fff; font-size: 0.88rem; font-weight: 600;
|
||||
text-decoration: none; transition: opacity 0.15s;
|
||||
}
|
||||
.action-link:hover { opacity: 0.88; }
|
||||
|
||||
/* Inline button row */
|
||||
.btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
||||
|
||||
/* ── Text utilities ── */
|
||||
|
||||
/* Small muted helper text below inputs */
|
||||
.hint { font-size: 0.78rem; color: var(--pg-dim); margin-top: 0.35rem; line-height: 1.5; }
|
||||
|
||||
/* Section-level description paragraph */
|
||||
.section-note { font-size: 0.8rem; color: var(--pg-muted); margin-bottom: 0.85rem; line-height: 1.55; }
|
||||
|
||||
/* Inline code */
|
||||
code {
|
||||
font-size: 0.82rem; font-family: 'SF Mono', 'Fira Mono', 'Menlo', monospace;
|
||||
background: var(--pg-bg); border: 1px solid var(--pg-border);
|
||||
padding: 0.1rem 0.35rem; border-radius: 4px; color: var(--pg-accent);
|
||||
}
|
||||
|
||||
/* ── Feedback messages ── */
|
||||
.success { color: #4ade80; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
|
||||
.error { color: #f87171; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
|
||||
|
||||
/* ── Usage table (JS-rendered in settings) ── */
|
||||
.usage-table { border-collapse: collapse; width: 100%; min-width: 360px; }
|
||||
.usage-table th {
|
||||
padding: 0.35rem 0.5rem; font-size: 0.75rem; color: var(--pg-muted);
|
||||
font-weight: 600; text-align: right; border-bottom: 1px solid var(--pg-border);
|
||||
}
|
||||
.usage-table th:first-child { padding-left: 0; text-align: left; }
|
||||
.usage-table td {
|
||||
padding: 0.4rem 0.5rem; font-size: 0.82rem; color: var(--pg-muted); text-align: right;
|
||||
}
|
||||
.usage-table td:first-child { padding-left: 0; color: var(--pg-text); text-align: left; white-space: nowrap; }
|
||||
.usage-table td:last-child { color: var(--pg-text); font-weight: 600; }
|
||||
|
||||
/* ── Tool category header row (tools_settings.py generated) ── */
|
||||
.tool-cat-row td {
|
||||
padding: 0.75rem 0.9rem 0.3rem;
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.07em;
|
||||
text-transform: uppercase; color: var(--pg-dimmer);
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
}
|
||||
@@ -7,276 +7,118 @@
|
||||
<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>
|
||||
:root {
|
||||
--pg-bg: #0f1117; --pg-surface: #1a1d27;
|
||||
--pg-border: #2d3148;
|
||||
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
|
||||
--pg-dim: #64748b; --pg-dimmer: #475569;
|
||||
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
|
||||
--pg-border: #d0c8e8;
|
||||
--pg-text: #1a1228; --pg-muted: #5a5478;
|
||||
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
|
||||
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--pg-bg);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-weight: 450;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--pg-text);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--pg-surface);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--pg-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
|
||||
.nav-link.active { color: #a78bfa; }
|
||||
.nav-spacer { flex: 1; min-width: 0.5rem; }
|
||||
.nav-link.nav-logout { color: var(--pg-dimmer); }
|
||||
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
|
||||
|
||||
.logo {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
||||
.logo p { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.2rem; }
|
||||
|
||||
h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--pg-muted);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
}
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--pg-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--pg-bg);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 6px;
|
||||
color: var(--pg-text);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus { border-color: #7c3aed; }
|
||||
input[readonly] { color: var(--pg-muted); cursor: default; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
margin-top: 0.25rem;
|
||||
background: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button[type="submit"]:hover { background: #6d28d9; }
|
||||
|
||||
.error {
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #4ade80;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Server-generated persona list ── */
|
||||
.persona-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.persona-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
list-style: none; display: flex; flex-direction: column;
|
||||
gap: 0.5rem; margin-top: 0.5rem;
|
||||
}
|
||||
.persona-list li { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.persona-link {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--pg-bg);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 20px;
|
||||
color: #a78bfa;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
display: inline-block; padding: 0.3rem 0.75rem;
|
||||
background: var(--pg-bg); border: 1px solid var(--pg-border);
|
||||
border-radius: 20px; color: var(--pg-accent); font-size: 0.85rem;
|
||||
text-decoration: none; transition: border-color 0.15s;
|
||||
}
|
||||
.persona-link:hover { border-color: #7c3aed; }
|
||||
.persona-link:hover { border-color: var(--pg-action); }
|
||||
.persona-list li em { color: var(--pg-muted); font-size: 0.85rem; }
|
||||
|
||||
.persona-rename-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pg-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
background: none; border: 1px solid var(--pg-border);
|
||||
border-radius: 6px; color: var(--pg-muted); font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem; margin-top: 0.25rem;
|
||||
cursor: pointer; opacity: 0.7; transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.persona-rename-toggle:hover { opacity: 1; color: #a78bfa; }
|
||||
|
||||
.persona-rename-toggle:hover { opacity: 1; color: var(--pg-accent); }
|
||||
.persona-rename-form { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.persona-rename-form input[type="text"] {
|
||||
width: 12rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--pg-bg);
|
||||
border: 1px solid #7c3aed;
|
||||
border-radius: 6px;
|
||||
color: var(--pg-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
width: 12rem; padding: 0.3rem 0.6rem;
|
||||
border-color: var(--pg-action); font-size: 0.9rem;
|
||||
}
|
||||
.persona-rename-form button[type="submit"] {
|
||||
width: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
.persona-rename-cancel {
|
||||
background: none;
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 6px;
|
||||
color: var(--pg-muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.persona-rename-cancel:hover { border-color: var(--pg-muted); color: var(--pg-text); }
|
||||
|
||||
.add-persona {
|
||||
display: inline-block;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--pg-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.add-persona:hover { color: #a78bfa; }
|
||||
.persona-rename-form .btn-save { padding: 0.3rem 0.75rem; font-size: 0.85rem; }
|
||||
|
||||
/* ── Server-generated role badge ── */
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
display: inline-block; padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px; font-size: 0.78rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
}
|
||||
.role-badge.role-admin {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: #a78bfa;
|
||||
border: 1px solid rgba(124, 58, 237, 0.4);
|
||||
background: rgba(124,58,237,0.15); color: var(--pg-accent);
|
||||
border: 1px solid rgba(124,58,237,0.4);
|
||||
}
|
||||
.role-badge.role-user {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--pg-muted);
|
||||
background: rgba(100,116,139,0.12); color: var(--pg-muted);
|
||||
border: 1px solid var(--pg-border);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--pg-bg);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: 6px;
|
||||
color: var(--pg-text);
|
||||
font-size: 0.88rem;
|
||||
font-family: 'SF Mono', 'Fira Mono', 'Menlo', monospace;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
textarea:focus { border-color: #7c3aed; }
|
||||
/* ── 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>
|
||||
<body>
|
||||
<div class="card">
|
||||
<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 active">Settings</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</a>
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
|
||||
<div class="logo">
|
||||
<h1>Account Settings</h1>
|
||||
<p>Manage your account and personas.</p>
|
||||
</div>
|
||||
<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 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>
|
||||
<div class="page-wrap">
|
||||
<h1 class="page-title">Account Settings</h1>
|
||||
<p class="page-subtitle">Manage your account and personas.</p>
|
||||
|
||||
<!-- 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>
|
||||
@@ -288,26 +130,21 @@
|
||||
<label>Role</label>
|
||||
<span class="role-badge role-{{ user_role }}">{{ user_role }}</span>
|
||||
</div>
|
||||
<button type="button" id="show-rename-user" class="persona-rename-toggle"
|
||||
style="opacity:0.7; font-size:0.8rem; padding:0.3rem 0.6rem; border:1px solid var(--pg-border); border-radius:6px; margin-top:0.25rem;">
|
||||
<button type="button" id="show-rename-user" class="persona-rename-toggle">
|
||||
✏ Change username
|
||||
</button>
|
||||
<form id="rename-user-form" method="POST" action="/settings/username"
|
||||
style="display:none; margin-top:0.75rem;">
|
||||
<form id="rename-user-form" method="POST" action="/settings/username" style="display:none; margin-top:0.75rem;">
|
||||
<div class="field">
|
||||
<label for="new_username">New username</label>
|
||||
<input type="text" id="new_username" name="new_username"
|
||||
value="{{ username }}"
|
||||
pattern="[a-z_][a-z0-9_\-]{0,31}" required autofocus
|
||||
autocomplete="off" data-form-type="other">
|
||||
<p style="font-size:0.75rem; color:var(--pg-muted); margin-top:0.3rem;">
|
||||
Lowercase letters, digits, _ or - only. You will be logged out after renaming.
|
||||
</p>
|
||||
<p class="hint">Lowercase letters, digits, _ or - only. You will be logged out after renaming.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<button type="submit" style="flex:1; padding:0.5rem; background:#7c3aed; border:none; border-radius:6px; color:#fff; font-size:0.9rem; font-weight:600; cursor:pointer;">Save</button>
|
||||
<button type="button" id="cancel-rename-user"
|
||||
style="padding:0.5rem 0.9rem; background:none; border:1px solid var(--pg-border); border-radius:6px; color:var(--pg-muted); font-size:0.9rem; cursor:pointer;">Cancel</button>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn-save">Save</button>
|
||||
<button type="button" id="cancel-rename-user" class="btn-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -321,18 +158,15 @@
|
||||
placeholder="No Google account linked"
|
||||
style="{{ google_email == '' and 'color:var(--pg-dimmer)' or '' }}">
|
||||
</div>
|
||||
<p style="font-size:0.75rem; color:var(--pg-muted); margin-top:-0.5rem;">
|
||||
To link or change your Google account, contact Scott.
|
||||
</p>
|
||||
<p class="hint" style="margin-top:-0.5rem;">To link or change your Google account, contact Scott.</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Allowlist -->
|
||||
<div class="section">
|
||||
<h2>Email Allowlist</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
One regex pattern per line. The <code style="font-size:0.82rem; background:var(--pg-bg); padding:0.1rem 0.35rem; border-radius:4px;">email_send</code>
|
||||
tool will only send to addresses that match at least one pattern.
|
||||
Leave blank to block all outbound email.
|
||||
<p class="section-note">
|
||||
One regex pattern per line. The <code>email_send</code> tool will only send to addresses
|
||||
that match at least one pattern. Leave blank to block all outbound email.
|
||||
</p>
|
||||
<form method="POST" action="/settings/email-allowlist">
|
||||
<div class="field">
|
||||
@@ -341,118 +175,52 @@
|
||||
placeholder=".*@example\.com alice@example\.com"
|
||||
spellcheck="false">{{ email_allowlist }}</textarea>
|
||||
</div>
|
||||
<button type="submit">Save allowlist</button>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<!-- HTTP POST Allowlist -->
|
||||
<div class="section">
|
||||
<h2>Notifications</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
Configure how Inara reaches out proactively — reminders, cron jobs, and memory digests.
|
||||
<h2>HTTP POST Allowlist</h2>
|
||||
<p class="section-note">
|
||||
One URL prefix per line. The <code>http_post</code> tool will only POST to URLs that
|
||||
start with a listed prefix. Leave blank to block all outbound POST requests.
|
||||
</p>
|
||||
<a href="/settings/notifications"
|
||||
style="display:inline-block; padding:0.55rem 1rem; background:#7c3aed; border-radius:6px;
|
||||
color:#fff; font-size:0.88rem; font-weight:600; text-decoration:none;
|
||||
transition:background 0.15s;">
|
||||
Notification settings →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tool Permissions -->
|
||||
<div class="section">
|
||||
<h2>Tool Permissions</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.5rem; line-height:1.55;">
|
||||
Override the default confirmation gate for orchestrator tools.
|
||||
<strong>Allow list</strong> — tools that run without asking for confirmation.
|
||||
<strong>Deny list</strong> — tools that are always blocked for your account.
|
||||
One tool name per line.
|
||||
</p>
|
||||
<p style="font-size:0.78rem; color:var(--pg-muted); margin-bottom:0.85rem;">
|
||||
Tools requiring confirmation by default: <code>{{ confirm_required_tools }}</code>
|
||||
</p>
|
||||
<form method="POST" action="/settings/tool-policy">
|
||||
<div class="form-group">
|
||||
<label for="allow_list">Allow list (bypass confirmation)</label>
|
||||
<textarea id="allow_list" name="allow_list" rows="3"
|
||||
placeholder="reminders_clear cron_remove"
|
||||
autocomplete="off" spellcheck="false">{{ tool_allow }}</textarea>
|
||||
<form method="POST" action="/settings/http-allowlist">
|
||||
<div class="field">
|
||||
<label for="http_allowlist_ta">Allowed URL prefixes</label>
|
||||
<textarea id="http_allowlist_ta" name="prefixes" rows="5"
|
||||
placeholder="https://ha.dgrzone.com/api/webhook/ https://n8n.dgrzone.com/webhook/"
|
||||
spellcheck="false">{{ http_allowlist }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="deny_list">Deny list (always block)</label>
|
||||
<textarea id="deny_list" name="deny_list" rows="3"
|
||||
placeholder="shell_exec file_write"
|
||||
autocomplete="off" spellcheck="false">{{ tool_deny }}</textarea>
|
||||
</div>
|
||||
<button type="submit">Save tool permissions</button>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Browser cache -->
|
||||
<!-- Usage summary -->
|
||||
<div class="section" id="usage-section">
|
||||
<h2>Usage</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
<p class="section-note">
|
||||
Token consumption tracked for API-backed models (Gemini API, local OpenAI-compatible).
|
||||
Claude CLI calls are not metered.
|
||||
</p>
|
||||
<div id="usage-table-wrap" style="overflow-x:auto;">
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted);">Loading…</p>
|
||||
<div id="usage-table-wrap" class="usage-wrap">
|
||||
<p class="section-note">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Cache -->
|
||||
<div class="section">
|
||||
<h2>Browser Cache</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
<p class="section-note">
|
||||
Clears UI preferences stored in this browser: active mode, session ID, memory toggles,
|
||||
theme, font size, and context tier. Does not sign you out.
|
||||
</p>
|
||||
<button type="button" id="clear-ls-btn"
|
||||
style="padding:0.5rem 1rem; background:none; border:1px solid var(--pg-border); border-radius:6px;
|
||||
color:var(--pg-muted); font-size:0.88rem; font-weight:500; cursor:pointer;
|
||||
transition:border-color 0.15s, color 0.15s;">
|
||||
Clear browser cache
|
||||
</button>
|
||||
<span id="clear-ls-ok" style="display:none; margin-left:0.75rem; font-size:0.8rem; color:#4ade80;">
|
||||
Cleared.
|
||||
</span>
|
||||
<button type="button" id="clear-ls-btn" class="btn-secondary">Clear browser cache</button>
|
||||
<span id="clear-ls-ok">Cleared.</span>
|
||||
</div>
|
||||
|
||||
<!-- Model Registry link -->
|
||||
<div class="section">
|
||||
<h2>Model Registry</h2>
|
||||
|
||||
<!-- Quick-start card: shown only when no model is configured for chat role -->
|
||||
<div id="openrouter-quickstart" style="display:none; background:#1c1a0a; border:1px solid #78350f;
|
||||
border-radius:8px; padding:1rem; margin-bottom:1rem;">
|
||||
<p style="font-size:0.82rem; color:#fbbf24; font-weight:600; margin-bottom:0.4rem;">
|
||||
⚡ You're on the server default model
|
||||
</p>
|
||||
<p style="font-size:0.8rem; color:#d97706; margin-bottom:0.75rem; line-height:1.5;">
|
||||
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"
|
||||
style="display:inline-block; padding:0.5rem 0.9rem; background:#92400e; border-radius:6px;
|
||||
color:#fef3c7; font-size:0.85rem; font-weight:600; text-decoration:none;">
|
||||
Set up OpenRouter →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
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"
|
||||
style="display:inline-block; padding:0.55rem 1rem; background:#7c3aed; border-radius:6px;
|
||||
color:#fff; font-size:0.88rem; font-weight:600; text-decoration:none;
|
||||
transition:background 0.15s;">
|
||||
Manage models →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Change password -->
|
||||
<!-- Change Password -->
|
||||
<div class="section">
|
||||
<h2>Change Password</h2>
|
||||
<form method="POST" action="/settings/password" id="password-form">
|
||||
@@ -471,33 +239,33 @@
|
||||
<input type="password" id="confirm_password" name="confirm_password"
|
||||
autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit">Update password</button>
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Personas -->
|
||||
<!-- Sessions -->
|
||||
<div class="section">
|
||||
<h2>Sessions</h2>
|
||||
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
|
||||
<p class="section-note">
|
||||
Auto-name any sessions that still show a random ID, using their first message as the name.
|
||||
Only unnamed sessions are affected — existing names are left alone.
|
||||
</p>
|
||||
<button type="button" id="backfill-names-btn"
|
||||
style="padding:0.5rem 1rem; background:none; border:1px solid var(--pg-border); border-radius:6px;
|
||||
color:var(--pg-muted); font-size:0.88rem; font-weight:500; cursor:pointer;
|
||||
transition:border-color 0.15s, color 0.15s;">
|
||||
Auto-name old sessions
|
||||
</button>
|
||||
<span id="backfill-names-ok" style="display:none; margin-left:0.75rem; font-size:0.8rem; color:#4ade80;"></span>
|
||||
<button type="button" id="backfill-names-btn" class="btn-secondary">Auto-name old sessions</button>
|
||||
<span id="backfill-names-ok"
|
||||
class="ml-3 text-xs hidden"
|
||||
style="color:#4ade80"></span>
|
||||
</div>
|
||||
|
||||
<!-- Personas -->
|
||||
<div class="section">
|
||||
<h2>Personas</h2>
|
||||
<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>
|
||||
|
||||
@@ -523,16 +291,6 @@
|
||||
document.getElementById('show-rename-user').style.display = '';
|
||||
});
|
||||
|
||||
// Gemini key — "remove" link clears the input and submits the form
|
||||
const geminiRemove = document.getElementById('gemini-remove-link');
|
||||
if (geminiRemove) {
|
||||
geminiRemove.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.getElementById('gemini_api_key').value = '';
|
||||
document.querySelector('form[action="/settings/gemini-key"]').submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Clear localStorage (keeps JWT cookie — no sign-out)
|
||||
document.getElementById('clear-ls-btn').addEventListener('click', () => {
|
||||
localStorage.clear();
|
||||
@@ -543,9 +301,10 @@
|
||||
(async () => {
|
||||
try {
|
||||
const d = await fetch('/backend').then(r => r.json());
|
||||
const roles = d.available_roles || [];
|
||||
if (roles.length === 0) {
|
||||
document.getElementById('openrouter-quickstart').style.display = 'block';
|
||||
if ((d.available_roles || []).length === 0) {
|
||||
const el = document.getElementById('openrouter-quickstart');
|
||||
el.classList.remove('hidden');
|
||||
el.style.display = 'block';
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
@@ -558,7 +317,7 @@
|
||||
if (!resp.ok) throw new Error(resp.statusText);
|
||||
const rows_data = await resp.json();
|
||||
if (!rows_data.length) {
|
||||
wrap.innerHTML = '<p style="font-size:0.8rem;color:var(--pg-muted);">No usage recorded yet.</p>';
|
||||
wrap.innerHTML = '<p class="section-note">No usage recorded yet.</p>';
|
||||
return;
|
||||
}
|
||||
const fmt = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
||||
@@ -567,27 +326,22 @@
|
||||
? `<span title="${d.key}">${d.label}</span>`
|
||||
: `<span>${d.key}</span>`;
|
||||
return `<tr>
|
||||
<td style="padding:0.4rem 0.75rem 0.4rem 0; font-size:0.82rem; color:var(--pg-text); white-space:nowrap;">${labelCell}</td>
|
||||
<td style="padding:0.4rem 0.5rem; font-size:0.82rem; color:var(--pg-muted); text-align:right;">${d.calls}</td>
|
||||
<td style="padding:0.4rem 0.5rem; font-size:0.82rem; color:var(--pg-muted); text-align:right;">${fmt(d.prompt_tokens)}</td>
|
||||
<td style="padding:0.4rem 0.5rem; font-size:0.82rem; color:var(--pg-muted); text-align:right;">${fmt(d.completion_tokens)}</td>
|
||||
<td style="padding:0.4rem 0 0.4rem 0.5rem; font-size:0.82rem; color:var(--pg-text); text-align:right; font-weight:600;">${fmt(d.total_tokens)}</td>
|
||||
<td>${labelCell}</td>
|
||||
<td>${d.calls}</td>
|
||||
<td>${fmt(d.prompt_tokens)}</td>
|
||||
<td>${fmt(d.completion_tokens)}</td>
|
||||
<td>${fmt(d.total_tokens)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
wrap.innerHTML = `<table style="border-collapse:collapse; width:100%; min-width:360px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--pg-border);">
|
||||
<th style="padding:0.35rem 0.75rem 0.35rem 0; font-size:0.75rem; color:var(--pg-muted); font-weight:600; text-align:left;">Model</th>
|
||||
<th style="padding:0.35rem 0.5rem; font-size:0.75rem; color:var(--pg-muted); font-weight:600; text-align:right;">Calls</th>
|
||||
<th style="padding:0.35rem 0.5rem; font-size:0.75rem; color:var(--pg-muted); font-weight:600; text-align:right;">Prompt</th>
|
||||
<th style="padding:0.35rem 0.5rem; font-size:0.75rem; color:var(--pg-muted); font-weight:600; text-align:right;">Output</th>
|
||||
<th style="padding:0.35rem 0 0.35rem 0.5rem; font-size:0.75rem; color:var(--pg-muted); font-weight:600; text-align:right;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
wrap.innerHTML = `<table class="usage-table">
|
||||
<thead><tr>
|
||||
<th style="text-align:left">Model</th>
|
||||
<th>Calls</th><th>Prompt</th><th>Output</th><th>Total</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
wrap.innerHTML = `<p style="font-size:0.8rem;color:var(--pg-muted);">Could not load usage data.</p>`;
|
||||
wrap.innerHTML = '<p class="section-note">Could not load usage data.</p>';
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -608,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;
|
||||
|
||||
@@ -142,6 +142,15 @@
|
||||
|
||||
.header-emoji.processing { animation: shimmer 0.75s ease-in-out infinite; }
|
||||
|
||||
@keyframes border-pulse {
|
||||
0%, 100% { box-shadow: inset 0 0 15px var(--amber-glow); }
|
||||
50% { box-shadow: inset 0 0 30px var(--amber-glow); }
|
||||
}
|
||||
|
||||
body.processing {
|
||||
animation: border-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
header .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
||||
header .subtitle { font-size: 0.78rem; color: var(--muted); }
|
||||
|
||||
@@ -363,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;
|
||||
@@ -614,18 +652,34 @@
|
||||
.copy-btn:hover { color: var(--text); border-color: var(--muted); }
|
||||
.copy-btn.copied { color: var(--success); border-color: var(--success-dim); }
|
||||
|
||||
/* Model tag — shown at the bottom of every assistant message */
|
||||
.model-tag {
|
||||
display: block;
|
||||
font-size: 0.67rem;
|
||||
color: #475569;
|
||||
margin-top: 0.55rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid #2d3148;
|
||||
text-align: right;
|
||||
/* Message metadata — shown in the hover bar below the bubble */
|
||||
.msg-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.62rem;
|
||||
color: var(--dim);
|
||||
letter-spacing: 0.02em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.model-tag.fallback { color: #f59e0b; }
|
||||
.msg-meta-model {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-meta-model.fallback { color: #f59e0b; }
|
||||
.msg-meta-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.msg-meta-badge.otr { background: #1e1b4b; color: #818cf8; }
|
||||
[data-theme="light"] .msg-meta-badge.otr { background: #ede9fe; color: #5b21b6; }
|
||||
|
||||
/* Retry button — shown in error message bubbles */
|
||||
.retry-btn {
|
||||
@@ -807,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);
|
||||
@@ -881,11 +987,14 @@
|
||||
#stop:hover { background: #5c1a1a; }
|
||||
|
||||
#session-id {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--border);
|
||||
padding: 0 20px 6px;
|
||||
background: var(--surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
#session-id:empty { display: none; }
|
||||
|
||||
/* ── Message wrappers (edit/delete controls) ──────────────── */
|
||||
.msg-wrapper {
|
||||
|
||||
213
cortex/static/tools_settings.html
Normal file
213
cortex/static/tools_settings.html
Normal file
@@ -0,0 +1,213 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tool Settings — Cortex</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 tool table ── */
|
||||
.table-section-label {
|
||||
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--pg-dimmer);
|
||||
margin: 1.75rem 0 0.6rem;
|
||||
}
|
||||
.tool-table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
background: var(--pg-surface); border: 1px solid var(--pg-border);
|
||||
border-radius: 0.75rem; overflow: hidden; margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tool-table th {
|
||||
text-align: left; padding: 0.55rem 0.9rem;
|
||||
border-bottom: 1px solid var(--pg-border);
|
||||
color: var(--pg-muted); font-weight: 600; font-size: 0.78rem;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.tool-table td { padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--pg-border); vertical-align: middle; }
|
||||
.tool-table tr:last-child td { border-bottom: none; }
|
||||
.tool-table tr:hover td { background: rgba(124,58,237,0.04); }
|
||||
.tool-name { font-family: monospace; font-size: 0.82rem; }
|
||||
|
||||
/* 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; }
|
||||
.risk-medium { background: rgba(234,179,8,0.12); color: #fbbf24; }
|
||||
.risk-high { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||
[data-theme="light"] .risk-low { background: rgba(34,197,94,0.15); color: #16a34a; }
|
||||
[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 (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;
|
||||
}
|
||||
.auto-on { background: rgba(124,58,237,0.12); color: #a78bfa; }
|
||||
.auto-off { background: rgba(148,163,184,0.12); color: var(--pg-dimmer); }
|
||||
[data-theme="light"] .auto-on { color: #7c3aed; }
|
||||
|
||||
/* 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; }
|
||||
</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 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>
|
||||
|
||||
<div class="page-wrap">
|
||||
<h1 class="page-title">Tool Settings</h1>
|
||||
<p class="page-subtitle">
|
||||
Control which orchestrator tools are available. The risk level sets an automatic threshold;
|
||||
whitelist and blacklist let you fine-tune individual tools beyond that.
|
||||
</p>
|
||||
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<form method="POST" action="/settings/tools" id="tools-form">
|
||||
|
||||
<!-- 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="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="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="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 (server-generated) -->
|
||||
{{ tool_table_html }}
|
||||
|
||||
<!-- 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 class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1">{{ confirm_required_tools }}</code>
|
||||
</p>
|
||||
<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="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>
|
||||
<p class="hint">These tools are always blocked regardless of risk policy.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn-submit w-full md:w-96">Save tool settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const riskRank = { "": 99, "low": 0, "medium": 1, "high": 2 };
|
||||
const toolRisk = {{ tool_risk_json }};
|
||||
|
||||
const sel = document.getElementById('max-risk-sel');
|
||||
|
||||
function updateAutoPills() {
|
||||
const maxRank = riskRank[sel.value] ?? 99;
|
||||
document.querySelectorAll('[data-tool-risk]').forEach(row => {
|
||||
const risk = row.dataset.toolRisk;
|
||||
const pill = row.querySelector('.auto-pill');
|
||||
const isAuto = riskRank[risk] <= maxRank;
|
||||
pill.textContent = isAuto ? 'auto ✓' : 'excluded';
|
||||
pill.className = 'auto-pill ' + (isAuto ? 'auto-on' : 'auto-off');
|
||||
});
|
||||
}
|
||||
|
||||
sel.addEventListener('change', updateAutoPills);
|
||||
updateAutoPills();
|
||||
|
||||
// Color the override selects
|
||||
document.querySelectorAll('.override-sel').forEach(s => {
|
||||
function refresh() {
|
||||
s.className = 'override-sel';
|
||||
if (s.value === 'whitelist') s.classList.add('forced-on');
|
||||
if (s.value === 'blacklist') s.classList.add('forced-off');
|
||||
}
|
||||
s.addEventListener('change', refresh);
|
||||
refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
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()
|
||||
|
||||
@@ -17,7 +17,7 @@ from google.genai import types
|
||||
|
||||
# ── Callable imports ──────────────────────────────────────────────────────────
|
||||
|
||||
from tools.web import search as _web_search, http_fetch as _http_fetch, web_read as _web_read
|
||||
from tools.web import search as _web_search, http_fetch as _http_fetch, web_read as _web_read, http_post as _http_post
|
||||
from tools.ae_knowledge import (
|
||||
journal_list as _ae_journal_list,
|
||||
journal_search as _ae_journal_search,
|
||||
@@ -30,7 +30,19 @@ from tools.ae_knowledge import (
|
||||
journal_entry_prepend as _ae_journal_entry_prepend,
|
||||
)
|
||||
from tools.ae_tasks import task_list as _ae_task_list
|
||||
from tools.files import file_read as _file_read, file_list as _file_list, file_write as _file_write, session_search as _session_search, session_read as _session_read
|
||||
from tools.files import (
|
||||
project_file_read as _project_file_read,
|
||||
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,
|
||||
file_write as _file_write,
|
||||
session_read as _session_read,
|
||||
session_search as _session_search,
|
||||
)
|
||||
from tools.system import (
|
||||
shell_exec as _shell_exec,
|
||||
claude_allow_dir as _claude_allow_dir,
|
||||
@@ -63,14 +75,35 @@ from tools.scratch import (
|
||||
scratch_append as _scratch_append,
|
||||
scratch_clear as _scratch_clear,
|
||||
)
|
||||
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push
|
||||
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push, nc_talk_history as _nc_talk_history
|
||||
from tools.agent_notes import (
|
||||
agent_notes_read as _agent_notes_read,
|
||||
agent_notes_write as _agent_notes_write,
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -85,20 +118,26 @@ 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.agents as _mod_agents
|
||||
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"],
|
||||
"Files": ["file_read", "file_list", "file_write", "session_read", "session_search"],
|
||||
"Web": ["web_search", "http_fetch", "web_read", "http_post"],
|
||||
"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"],
|
||||
"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"],
|
||||
"Notifications": ["web_push", "email_send", "nc_talk_send"],
|
||||
"Notifications": ["web_push", "email_send", "nc_talk_send", "nc_talk_history"],
|
||||
"Aether Journals": [
|
||||
"ae_journal_list", "ae_journal_search",
|
||||
"ae_journal_entries_list", "ae_journal_entry_read",
|
||||
@@ -108,7 +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 ─────────────────────────────────────────────────────────
|
||||
@@ -117,6 +158,7 @@ _CALLABLES: dict[str, callable] = {
|
||||
"web_search": _web_search,
|
||||
"http_fetch": _http_fetch,
|
||||
"web_read": _web_read,
|
||||
"http_post": _http_post,
|
||||
"ae_journal_list": _ae_journal_list,
|
||||
"ae_journal_search": _ae_journal_search,
|
||||
"ae_journal_entry_read": _ae_journal_entry_read,
|
||||
@@ -127,6 +169,12 @@ _CALLABLES: dict[str, callable] = {
|
||||
"ae_journal_entry_append": _ae_journal_entry_append,
|
||||
"ae_journal_entry_prepend": _ae_journal_entry_prepend,
|
||||
"ae_task_list": _ae_task_list,
|
||||
"project_file_read": _project_file_read,
|
||||
"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,
|
||||
"file_write": _file_write,
|
||||
@@ -157,11 +205,25 @@ _CALLABLES: dict[str, callable] = {
|
||||
"email_send": _email_send,
|
||||
"nc_talk_send": _nc_talk_send,
|
||||
"web_push": _web_push,
|
||||
"nc_talk_history": _nc_talk_history,
|
||||
"agent_notes_read": _agent_notes_read,
|
||||
"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 ─────────────────────────────────────────────────
|
||||
@@ -179,8 +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.
|
||||
@@ -191,8 +263,128 @@ CONFIRM_REQUIRED: set[str] = {
|
||||
"shell_exec",
|
||||
"cron_remove",
|
||||
"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.
|
||||
# Unlisted tools default to "medium".
|
||||
#
|
||||
# low — read-only, sandboxed, no external side effects
|
||||
# medium — writes to local/controlled data, or reads beyond project scope,
|
||||
# or sends notifications to the same user
|
||||
# high — affects external systems, physical devices, other users,
|
||||
# or the host process/filesystem in ways that are hard to reverse
|
||||
TOOL_RISK: dict[str, str] = {
|
||||
# Web — read-only fetches are low; posting to external services is high
|
||||
"web_search": "low",
|
||||
"http_fetch": "low",
|
||||
"web_read": "low",
|
||||
"http_post": "high",
|
||||
|
||||
# Project Files — all read-only and project-sandboxed
|
||||
"project_file_read": "low",
|
||||
"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
|
||||
"file_read": "medium",
|
||||
"file_list": "medium",
|
||||
"file_write": "high",
|
||||
"session_read": "low",
|
||||
"session_search": "low",
|
||||
|
||||
# Shell — arbitrary execution and permission changes are high
|
||||
"shell_exec": "high",
|
||||
"claude_allow_dir": "high",
|
||||
|
||||
# System — read-only status is low; restart/update affect the live service
|
||||
"cortex_logs": "low",
|
||||
"cortex_status": "low",
|
||||
"cortex_restart": "high",
|
||||
"cortex_update": "high",
|
||||
|
||||
# Tasks — local persona data, all reversible
|
||||
"task_list": "low",
|
||||
"task_create": "low",
|
||||
"task_update": "low",
|
||||
"task_complete": "low",
|
||||
|
||||
# Cron — list is low; add/remove/toggle affect scheduled behavior
|
||||
"cron_list": "low",
|
||||
"cron_add": "medium",
|
||||
"cron_remove": "medium",
|
||||
"cron_toggle": "medium",
|
||||
|
||||
# Reminders — single-item ops are low; clear-all is medium
|
||||
"reminders_add": "low",
|
||||
"reminders_list": "low",
|
||||
"reminders_remove": "low",
|
||||
"reminders_clear": "medium",
|
||||
|
||||
# Scratchpad — local persona file, ephemeral by design
|
||||
"scratch_read": "low",
|
||||
"scratch_write": "low",
|
||||
"scratch_append": "low",
|
||||
"scratch_clear": "low",
|
||||
|
||||
# Notifications — push to same user is medium; external messages are high
|
||||
"web_push": "medium",
|
||||
"nc_talk_send": "high",
|
||||
"nc_talk_history": "low",
|
||||
"email_send": "high",
|
||||
|
||||
# Aether Journals — reads are low; writes to external DB are medium
|
||||
"ae_journal_list": "low",
|
||||
"ae_journal_search": "low",
|
||||
"ae_journal_entries_list": "low",
|
||||
"ae_journal_entry_read": "low",
|
||||
"ae_journal_entry_create": "medium",
|
||||
"ae_journal_entry_update": "medium",
|
||||
"ae_journal_entry_disable": "medium",
|
||||
"ae_journal_entry_append": "medium",
|
||||
"ae_journal_entry_prepend": "medium",
|
||||
|
||||
# Aether Tasks
|
||||
"ae_task_list": "low",
|
||||
|
||||
# Agent Notes — local persona file
|
||||
"agent_notes_read": "low",
|
||||
"agent_notes_write": "low",
|
||||
"agent_notes_append": "low",
|
||||
"agent_notes_clear": "low",
|
||||
|
||||
# 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}
|
||||
|
||||
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
|
||||
|
||||
|
||||
@@ -206,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
|
||||
@@ -216,6 +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)
|
||||
@@ -309,18 +505,50 @@ OPENAI_TOOL_SCHEMAS: list[dict] = _build_openai_tools()
|
||||
|
||||
# ── Role-filtered tool access ─────────────────────────────────────────────────
|
||||
|
||||
def _apply_risk_policy(
|
||||
allowed: set[str],
|
||||
max_risk: str | None,
|
||||
whitelist: list[str] | None,
|
||||
blacklist: list[str] | None,
|
||||
) -> set[str]:
|
||||
"""Apply risk-level filtering on top of an already role-gated allowed set.
|
||||
|
||||
Filtering order (each step can only restrict or restore within what the
|
||||
role already permits — risk policy can never elevate above role):
|
||||
|
||||
1. max_risk auto-include: keep tools whose risk ≤ max_risk
|
||||
2. whitelist union: force-add specific tools (still role-gated)
|
||||
3. blacklist subtract: force-remove specific tools
|
||||
|
||||
When max_risk is None, all role-allowed tools remain (no risk filter).
|
||||
"""
|
||||
if max_risk is not None:
|
||||
max_rank = _RISK_RANK.get(max_risk, 2)
|
||||
auto = {n for n in allowed if _RISK_RANK.get(TOOL_RISK.get(n, "medium"), 1) <= max_rank}
|
||||
extra = {n for n in (whitelist or []) if n in allowed}
|
||||
allowed = (auto | extra)
|
||||
if blacklist:
|
||||
allowed -= set(blacklist)
|
||||
return allowed
|
||||
|
||||
|
||||
def get_tools_for_role(
|
||||
role: str,
|
||||
tool_list: list[str] | None = None,
|
||||
max_risk: str | None = None,
|
||||
whitelist: list[str] | None = None,
|
||||
blacklist: list[str] | None = None,
|
||||
) -> tuple[list, dict]:
|
||||
"""Return (gemini_tool_declarations, callables_dict) filtered to tools the role can use.
|
||||
"""Return (gemini_tool_declarations, callables_dict) filtered to what the role can use.
|
||||
|
||||
role — user access level ("user" | "admin"); gates admin-only tools
|
||||
tool_list — optional explicit allow-list from role config (e.g. coder role);
|
||||
intersected with the access-level filter so it can only restrict,
|
||||
never elevate privileges
|
||||
tool_list — optional model-level allow-list; intersected so it can only restrict
|
||||
max_risk — auto-include tools at/below this risk level ("low"|"medium"|"high")
|
||||
whitelist — force-include specific tools above max_risk (still role-gated)
|
||||
blacklist — force-exclude specific tools regardless of max_risk
|
||||
"""
|
||||
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
|
||||
allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist)
|
||||
if tool_list is not None:
|
||||
allowed &= set(tool_list)
|
||||
decls = [d for d in _ALL_DECLARATIONS if d.name in allowed]
|
||||
@@ -331,13 +559,131 @@ def get_tools_for_role(
|
||||
def get_openai_tools_for_role(
|
||||
role: str,
|
||||
tool_list: list[str] | None = None,
|
||||
max_risk: str | None = None,
|
||||
whitelist: list[str] | None = None,
|
||||
blacklist: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return OpenAI tool schemas filtered to tools the role can use.
|
||||
"""Return OpenAI tool schemas filtered to what the role can use.
|
||||
|
||||
role — user access level ("user" | "admin")
|
||||
tool_list — optional explicit allow-list from role config
|
||||
tool_list — optional model-level allow-list
|
||||
max_risk — auto-include tools at/below this risk level
|
||||
whitelist — force-include specific tools above max_risk
|
||||
blacklist — force-exclude specific tools
|
||||
"""
|
||||
allowed = {name for name in _CALLABLES if _role_allowed(name, role)}
|
||||
allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist)
|
||||
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"],
|
||||
),
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
"""
|
||||
File read/write/search tools — restricted to known-safe directory roots.
|
||||
File read/write/search tools — two access scopes.
|
||||
|
||||
Lets the orchestrator read local files (documentation, notes, config references)
|
||||
and search past session logs without exposing arbitrary filesystem access.
|
||||
All paths are resolved and checked against an allowlist of roots before any
|
||||
read or write is performed.
|
||||
Project scope (no admin required):
|
||||
project_file_read — read a file with optional line-range (offset)
|
||||
project_file_list — list a directory with sizes + timestamps
|
||||
file_stat — size, modified time, line count for a path
|
||||
file_grep — regex search with context lines; up to 50 matches
|
||||
file_syntax_check — py_compile (.py) or json.loads (.json) check
|
||||
|
||||
System scope (admin-only):
|
||||
file_read — read a file from ~/agents_sync/, ~/OSIT_dev/, etc.
|
||||
file_list — list a directory (same roots)
|
||||
file_write — write/append (~/agents_sync/ + Cortex home/)
|
||||
|
||||
Session tools (user-level, persona-isolated):
|
||||
session_read — read a session log by date
|
||||
session_search — keyword search across session logs
|
||||
|
||||
All project-scope tools are restricted to the Cortex project root:
|
||||
~/agents_sync/projects/Cortex_and_Inara_dev/
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from google.genai import types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Directories the orchestrator is allowed to read from.
|
||||
# Paths are resolved (symlinks followed, ~ expanded) at import time.
|
||||
# ── Access roots ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Project root: two levels up from cortex/tools/files.py → Cortex_and_Inara_dev/
|
||||
_PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.resolve()
|
||||
|
||||
# System-wide read roots
|
||||
def _build_allowed_roots() -> list[Path]:
|
||||
roots = [
|
||||
Path.home() / "agents_sync",
|
||||
@@ -34,88 +55,24 @@ def _build_allowed_roots() -> list[Path]:
|
||||
|
||||
_ALLOWED_ROOTS: list[Path] = _build_allowed_roots()
|
||||
|
||||
# Hard cap on file size to prevent accidental context blowout
|
||||
_MAX_BYTES = 50_000 # ~50 KB
|
||||
_MAX_LINES = 500
|
||||
# Write is tighter
|
||||
_WRITE_ROOTS: list[Path] = [Path.home() / "agents_sync"]
|
||||
|
||||
# Size limits
|
||||
_MAX_BYTES = 50_000
|
||||
_MAX_LINES = 500
|
||||
_MAX_GREP_MATCHES = 50
|
||||
|
||||
|
||||
async def file_read(path: str, max_lines: int | None = None) -> str:
|
||||
"""Read a local file and return its contents as a string.
|
||||
|
||||
Only files within allowed directories can be read:
|
||||
~/agents_sync/, ~/OSIT_dev/, ~/DgrZone_Nextcloud/, ~/OSIT_Nextcloud/
|
||||
|
||||
Args:
|
||||
path: Absolute or home-relative path to the file (e.g. ~/agents_sync/CLAUDE.md).
|
||||
max_lines: Optional line limit (default 500, hard cap). Use for large files.
|
||||
|
||||
Returns the file contents (truncated if over the size limit), or an error message.
|
||||
"""
|
||||
return await asyncio.to_thread(_sync_file_read, path, max_lines)
|
||||
|
||||
|
||||
def _sync_file_read(path: str, max_lines: int | None) -> str:
|
||||
# Expand ~ and resolve to absolute path
|
||||
def _is_project_allowed(resolved: Path) -> bool:
|
||||
try:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
# Security check — must be under an allowed root
|
||||
if not _is_allowed(resolved):
|
||||
allowed_str = ", ".join(str(r) for r in _ALLOWED_ROOTS)
|
||||
return (
|
||||
f"Access denied: {resolved}\n"
|
||||
f"Allowed directories: {allowed_str}"
|
||||
)
|
||||
|
||||
if not resolved.exists():
|
||||
return f"File not found: {resolved}"
|
||||
|
||||
if not resolved.is_file():
|
||||
# If it's a directory, list its contents instead
|
||||
try:
|
||||
entries = sorted(resolved.iterdir())
|
||||
names = [e.name + ("/" if e.is_dir() else "") for e in entries[:100]]
|
||||
return f"Directory listing for {resolved}:\n" + "\n".join(names)
|
||||
except Exception as e:
|
||||
return f"Cannot list directory: {e}"
|
||||
|
||||
# Read the file
|
||||
try:
|
||||
raw = resolved.read_bytes()
|
||||
except Exception as e:
|
||||
return f"Read error: {e}"
|
||||
|
||||
# Binary files
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return f"Binary file (not readable as text): {resolved} [{len(raw)} bytes]"
|
||||
|
||||
# Apply line limit
|
||||
limit = min(max_lines or _MAX_LINES, _MAX_LINES)
|
||||
lines = text.splitlines()
|
||||
truncated = False
|
||||
|
||||
if len(lines) > limit:
|
||||
lines = lines[:limit]
|
||||
truncated = True
|
||||
|
||||
# Apply byte cap as a final safety net
|
||||
result = "\n".join(lines)
|
||||
if len(result) > _MAX_BYTES:
|
||||
result = result[:_MAX_BYTES]
|
||||
truncated = True
|
||||
|
||||
if truncated:
|
||||
result += f"\n\n… [truncated — file has {len(text.splitlines())} lines total]"
|
||||
|
||||
return result
|
||||
resolved.relative_to(_PROJECT_ROOT)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_allowed(resolved: Path) -> bool:
|
||||
"""Check that resolved path is under one of the allowed roots."""
|
||||
for root in _ALLOWED_ROOTS:
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
@@ -125,12 +82,6 @@ def _is_allowed(resolved: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Write is restricted to a tighter set of paths to limit blast radius.
|
||||
_WRITE_ROOTS: list[Path] = [
|
||||
Path.home() / "agents_sync",
|
||||
]
|
||||
|
||||
|
||||
def _is_write_allowed(resolved: Path) -> bool:
|
||||
for root in _WRITE_ROOTS:
|
||||
try:
|
||||
@@ -138,63 +89,360 @@ def _is_write_allowed(resolved: Path) -> bool:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
# Also allow the Cortex home/ directory (persona memory, tasks, etc.)
|
||||
try:
|
||||
from config import settings
|
||||
cortex_home = settings.home_root()
|
||||
resolved.relative_to(cortex_home)
|
||||
resolved.relative_to(settings.home_root())
|
||||
return True
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
async def file_list(path: str) -> str:
|
||||
"""List the contents of a directory.
|
||||
# ── Shared implementations ────────────────────────────────────────────────────
|
||||
|
||||
Returns names of files and subdirectories with type indicators (/ for dirs).
|
||||
Same allow-list as file_read.
|
||||
"""
|
||||
return await asyncio.to_thread(_sync_file_list, path)
|
||||
|
||||
|
||||
def _sync_file_list(path: str) -> str:
|
||||
def _read_impl(path_str: str, offset: int | None, max_lines: int | None, is_allowed_fn) -> str:
|
||||
try:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
resolved = Path(path_str).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
if not _is_allowed(resolved):
|
||||
allowed_str = ", ".join(str(r) for r in _ALLOWED_ROOTS)
|
||||
return f"Access denied: {resolved}\nAllowed directories: {allowed_str}"
|
||||
if not is_allowed_fn(resolved):
|
||||
return f"Access denied: {resolved}"
|
||||
|
||||
if not resolved.exists():
|
||||
return f"File not found: {resolved}"
|
||||
|
||||
if not resolved.is_file():
|
||||
try:
|
||||
entries = sorted(resolved.iterdir())
|
||||
names = [e.name + ("/" if e.is_dir() else "") for e in entries[:100]]
|
||||
return f"Directory listing for {resolved}:\n" + "\n".join(names)
|
||||
except Exception as e:
|
||||
return f"Cannot list directory: {e}"
|
||||
|
||||
try:
|
||||
raw = resolved.read_bytes()
|
||||
except Exception as e:
|
||||
return f"Read error: {e}"
|
||||
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return f"Binary file (not readable as text): {resolved} [{len(raw)} bytes]"
|
||||
|
||||
all_lines = text.splitlines()
|
||||
total = len(all_lines)
|
||||
|
||||
# offset is 1-based; default = start of file
|
||||
start = max(0, (offset or 1) - 1)
|
||||
working = all_lines[start:]
|
||||
|
||||
limit = min(max_lines or _MAX_LINES, _MAX_LINES)
|
||||
truncated = False
|
||||
if len(working) > limit:
|
||||
working = working[:limit]
|
||||
truncated = True
|
||||
|
||||
result = "\n".join(working)
|
||||
if len(result) > _MAX_BYTES:
|
||||
result = result[:_MAX_BYTES]
|
||||
truncated = True
|
||||
|
||||
end_line = start + len(working)
|
||||
header = f"[Lines {start + 1}–{end_line} of {total}]\n" if (start > 0 or truncated) else ""
|
||||
trailer = f"\n\n… [truncated — file has {total} lines; use offset={end_line + 1} to read more]" if truncated else ""
|
||||
|
||||
return header + result + trailer
|
||||
|
||||
|
||||
def _list_impl(path_str: str, is_allowed_fn) -> str:
|
||||
try:
|
||||
resolved = Path(path_str).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
if not is_allowed_fn(resolved):
|
||||
return f"Access denied: {resolved}"
|
||||
|
||||
if not resolved.exists():
|
||||
return f"Path not found: {resolved}"
|
||||
|
||||
if resolved.is_file():
|
||||
return f"{resolved} is a file, not a directory. Use file_read to read it."
|
||||
return f"{resolved} is a file. Use file_read / project_file_read to read it."
|
||||
|
||||
try:
|
||||
entries = sorted(resolved.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
|
||||
lines = []
|
||||
for e in entries[:200]:
|
||||
suffix = "/" if e.is_dir() else f" ({e.stat().st_size} bytes)" if e.is_file() else ""
|
||||
if e.is_dir():
|
||||
suffix = "/"
|
||||
else:
|
||||
try:
|
||||
st = e.stat()
|
||||
mtime = datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
suffix = f" ({st.st_size:,} B, {mtime})"
|
||||
except Exception:
|
||||
suffix = ""
|
||||
lines.append(f"{e.name}{suffix}")
|
||||
result = "\n".join(lines)
|
||||
if len(entries) > 200:
|
||||
result += f"\n… ({len(entries) - 200} more entries not shown)"
|
||||
result += f"\n… ({len(entries) - 200} more not shown)"
|
||||
return f"Contents of {resolved}:\n\n{result}"
|
||||
except Exception as e:
|
||||
return f"Cannot list directory: {e}"
|
||||
|
||||
|
||||
async def file_write(path: str, content: str, mode: str = "overwrite") -> str:
|
||||
"""Write content to a file.
|
||||
# ── Project-scoped tools ──────────────────────────────────────────────────────
|
||||
|
||||
mode: 'overwrite' (default) replaces the file; 'append' adds to the end.
|
||||
Write-allowed paths: ~/agents_sync/ and the Cortex home/ directory.
|
||||
Parent directories are created if they don't exist.
|
||||
"""
|
||||
async def project_file_read(path: str, offset: int | None = None, max_lines: int | None = None) -> str:
|
||||
"""Read a file within the Cortex project directory, with optional line range."""
|
||||
return await asyncio.to_thread(_read_impl, path, offset, max_lines, _is_project_allowed)
|
||||
|
||||
|
||||
async def project_file_list(path: str) -> str:
|
||||
"""List directory contents within the Cortex project directory, with sizes and timestamps."""
|
||||
return await asyncio.to_thread(_list_impl, path, _is_project_allowed)
|
||||
|
||||
|
||||
async def file_stat(path: str) -> str:
|
||||
"""Return metadata for a file or directory: type, size, modified time, line count."""
|
||||
return await asyncio.to_thread(_sync_file_stat, path)
|
||||
|
||||
|
||||
def _sync_file_stat(path_str: str) -> str:
|
||||
try:
|
||||
resolved = Path(path_str).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
if not _is_project_allowed(resolved):
|
||||
return f"Access denied: {resolved}\nProject root: {_PROJECT_ROOT}"
|
||||
|
||||
if not resolved.exists():
|
||||
return f"Path not found: {resolved}"
|
||||
|
||||
try:
|
||||
st = resolved.stat()
|
||||
except Exception as e:
|
||||
return f"Cannot stat: {e}"
|
||||
|
||||
modified = datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
lines = [
|
||||
f"Path: {resolved}",
|
||||
f"Type: {'directory' if resolved.is_dir() else 'file'}",
|
||||
f"Size: {st.st_size:,} bytes",
|
||||
f"Modified: {modified}",
|
||||
]
|
||||
|
||||
if resolved.is_file():
|
||||
try:
|
||||
raw = resolved.read_bytes()
|
||||
if b'\x00' not in raw[:1024]:
|
||||
lines.append(f"Lines: {len(raw.decode('utf-8', errors='replace').splitlines())}")
|
||||
except Exception:
|
||||
pass
|
||||
elif resolved.is_dir():
|
||||
try:
|
||||
entries = list(resolved.iterdir())
|
||||
n_files = sum(1 for e in entries if e.is_file())
|
||||
n_dirs = sum(1 for e in entries if e.is_dir())
|
||||
lines.append(f"Contents: {n_files} file(s), {n_dirs} subdirector{'y' if n_dirs == 1 else 'ies'}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def file_grep(path: str, pattern: str, context_lines: int = 2, recursive: bool = True) -> str:
|
||||
"""Search for a regex pattern in a file or directory, returning matching lines with context."""
|
||||
return await asyncio.to_thread(_sync_file_grep, path, pattern, context_lines, recursive)
|
||||
|
||||
|
||||
def _sync_file_grep(path_str: str, pattern: str, context_lines: int, recursive: bool) -> str:
|
||||
try:
|
||||
resolved = Path(path_str).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
if not _is_project_allowed(resolved):
|
||||
return f"Access denied: {resolved}\nProject root: {_PROJECT_ROOT}"
|
||||
|
||||
if not resolved.exists():
|
||||
return f"Path not found: {resolved}"
|
||||
|
||||
try:
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
except re.error as e:
|
||||
return f"Invalid regex pattern: {e}"
|
||||
|
||||
ctx = max(0, min(context_lines, 5))
|
||||
|
||||
if resolved.is_file():
|
||||
files_to_search = [resolved]
|
||||
elif recursive:
|
||||
files_to_search = sorted(f for f in resolved.rglob("*") if f.is_file())
|
||||
else:
|
||||
files_to_search = sorted(f for f in resolved.iterdir() if f.is_file())
|
||||
|
||||
total_matches = 0
|
||||
sections: list[str] = []
|
||||
capped = False
|
||||
|
||||
for fp in files_to_search:
|
||||
if total_matches >= _MAX_GREP_MATCHES:
|
||||
capped = True
|
||||
break
|
||||
try:
|
||||
raw = fp.read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if b'\x00' in raw[:1024]:
|
||||
continue # skip binary
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
file_lines = text.splitlines()
|
||||
match_indices = [i for i, line in enumerate(file_lines) if regex.search(line)]
|
||||
if not match_indices:
|
||||
continue
|
||||
|
||||
total_matches += len(match_indices)
|
||||
|
||||
try:
|
||||
label = str(fp.relative_to(_PROJECT_ROOT))
|
||||
except ValueError:
|
||||
label = str(fp)
|
||||
|
||||
file_output = [f"── {label} ──"]
|
||||
printed: set[int] = set()
|
||||
|
||||
for mi in match_indices:
|
||||
start = max(0, mi - ctx)
|
||||
end = min(len(file_lines), mi + ctx + 1)
|
||||
if printed and start > max(printed) + 1:
|
||||
file_output.append(" ···")
|
||||
for j in range(start, end):
|
||||
if j not in printed:
|
||||
marker = "►" if j == mi else " "
|
||||
file_output.append(f" {j + 1:4d}{marker} {file_lines[j]}")
|
||||
printed.add(j)
|
||||
|
||||
sections.append("\n".join(file_output))
|
||||
|
||||
if not sections:
|
||||
return f"No matches for '{pattern}' in {resolved}"
|
||||
|
||||
cap_note = f" (capped at {_MAX_GREP_MATCHES})" if capped else ""
|
||||
header = f"grep '{pattern}' — {total_matches} match(es){cap_note}:"
|
||||
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)
|
||||
|
||||
|
||||
def _sync_file_syntax_check(path_str: str) -> str:
|
||||
try:
|
||||
resolved = Path(path_str).expanduser().resolve()
|
||||
except Exception as e:
|
||||
return f"Invalid path: {e}"
|
||||
|
||||
if not _is_project_allowed(resolved):
|
||||
return f"Access denied: {resolved}\nProject root: {_PROJECT_ROOT}"
|
||||
|
||||
if not resolved.exists():
|
||||
return f"File not found: {resolved}"
|
||||
|
||||
if not resolved.is_file():
|
||||
return f"Not a file: {resolved}"
|
||||
|
||||
suffix = resolved.suffix.lower()
|
||||
|
||||
if suffix == ".py":
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "py_compile", str(resolved)],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return f"OK — {resolved.name}: syntax valid"
|
||||
err = (result.stderr or result.stdout).strip()
|
||||
return f"Syntax error in {resolved.name}:\n{err}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"Timeout running py_compile on {resolved.name}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
elif suffix == ".json":
|
||||
try:
|
||||
text = resolved.read_text(encoding="utf-8")
|
||||
json.loads(text)
|
||||
return f"OK — {resolved.name}: valid JSON"
|
||||
except json.JSONDecodeError as e:
|
||||
return f"JSON error in {resolved.name}: {e}"
|
||||
except Exception as e:
|
||||
return f"Error reading {resolved.name}: {e}"
|
||||
|
||||
else:
|
||||
return f"Syntax check not supported for '{suffix}' files. Supported: .py, .json"
|
||||
|
||||
|
||||
# ── System-scoped tools ───────────────────────────────────────────────────────
|
||||
|
||||
async def file_read(path: str, offset: int | None = None, max_lines: int | None = None) -> str:
|
||||
"""Read a local file from the broader system. Allowed: ~/agents_sync/, ~/OSIT_dev/, etc. ADMIN ONLY."""
|
||||
return await asyncio.to_thread(_read_impl, path, offset, max_lines, _is_allowed)
|
||||
|
||||
|
||||
async def file_list(path: str) -> str:
|
||||
"""List directory contents from the broader system. ADMIN ONLY."""
|
||||
return await asyncio.to_thread(_list_impl, path, _is_allowed)
|
||||
|
||||
|
||||
async def file_write(path: str, content: str, mode: str = "overwrite") -> str:
|
||||
"""Write or append content to a file. Write roots: ~/agents_sync/ and Cortex home/. ADMIN ONLY."""
|
||||
return await asyncio.to_thread(_sync_file_write, path, content, mode)
|
||||
|
||||
|
||||
@@ -227,16 +475,13 @@ def _sync_file_write(path: str, content: str, mode: str) -> str:
|
||||
return f"Write error: {e}"
|
||||
|
||||
|
||||
# ── Session tools ─────────────────────────────────────────────────────────────
|
||||
|
||||
_SEARCH_EXCERPT_CHARS = 150
|
||||
|
||||
|
||||
async def session_read(date: str) -> str:
|
||||
"""Read a full session log by date (YYYY-MM-DD).
|
||||
|
||||
Returns the complete session log for that date. If the date is not found,
|
||||
lists the most recent available dates instead.
|
||||
Only reads the current user's own sessions (per-persona isolation via ContextVars).
|
||||
"""
|
||||
"""Read a full session log by date (YYYY-MM-DD)."""
|
||||
return await asyncio.to_thread(_sync_session_read, date.strip())
|
||||
|
||||
|
||||
@@ -259,11 +504,7 @@ def _sync_session_read(date: str) -> str:
|
||||
|
||||
|
||||
async def session_search(query: str, limit: int = 5) -> str:
|
||||
"""Search past session logs for a keyword or phrase.
|
||||
|
||||
Returns up to `limit` matching excerpts with session dates, newest first.
|
||||
Only searches the current user's own sessions (per-persona isolation via ContextVars).
|
||||
"""
|
||||
"""Search past session logs for a keyword or phrase."""
|
||||
return await asyncio.to_thread(_sync_session_search, query, limit)
|
||||
|
||||
|
||||
@@ -273,7 +514,7 @@ def _sync_session_search(query: str, limit: int) -> str:
|
||||
if not sessions_dir.exists():
|
||||
return "No session logs found."
|
||||
|
||||
limit = max(1, min(limit, 20))
|
||||
limit = max(1, min(limit, 20))
|
||||
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
||||
session_files = sorted(sessions_dir.glob("*.md"), reverse=True)
|
||||
|
||||
@@ -288,8 +529,8 @@ def _sync_session_search(query: str, limit: int) -> str:
|
||||
for m in pattern.finditer(text):
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
start = max(0, m.start() - _SEARCH_EXCERPT_CHARS)
|
||||
end = min(len(text), m.end() + _SEARCH_EXCERPT_CHARS)
|
||||
start = max(0, m.start() - _SEARCH_EXCERPT_CHARS)
|
||||
end = min(len(text), m.end() + _SEARCH_EXCERPT_CHARS)
|
||||
excerpt = text[start:end].strip()
|
||||
if start > 0:
|
||||
excerpt = "…" + excerpt
|
||||
@@ -299,27 +540,176 @@ def _sync_session_search(query: str, limit: int) -> str:
|
||||
|
||||
if not matches:
|
||||
return f"No matches for '{query}' across {len(session_files)} session logs."
|
||||
|
||||
header = f"Session search: '{query}' — {len(matches)} match(es) across {len(session_files)} logs\n"
|
||||
return header + "\n\n".join(matches)
|
||||
|
||||
|
||||
# ── Declarations ──────────────────────────────────────────────────────────────
|
||||
|
||||
DECLARATIONS = [
|
||||
# Project-scoped
|
||||
types.FunctionDeclaration(
|
||||
name="file_read",
|
||||
name="project_file_read",
|
||||
description=(
|
||||
"Read a local file and return its contents. "
|
||||
"Allowed directories: ~/agents_sync/, ~/OSIT_dev/, ~/DgrZone_Nextcloud/, ~/OSIT_Nextcloud/, "
|
||||
"and the Cortex home/ directory (persona memory, tool audit logs, etc.). "
|
||||
"Use this to read documentation, notes, CLAUDE.md files, config references, "
|
||||
"or tool audit logs at home/{user}/tool_audit/YYYY-MM-DD.jsonl. "
|
||||
"If given a directory path, returns a directory listing instead."
|
||||
"Read a file within the Cortex project directory (source code, docs, config, persona files). "
|
||||
"Supports reading a specific line range via offset — use to page through large files "
|
||||
"without re-reading from the top. If given a directory path, returns a listing instead. "
|
||||
"Project root: ~/agents_sync/projects/Cortex_and_Inara_dev/"
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(type=types.Type.STRING, description="Absolute or home-relative path to the file (e.g. ~/agents_sync/CLAUDE.md or /home/scott/agents_sync/tasks/01_todo/)"),
|
||||
"max_lines": types.Schema(type=types.Type.INTEGER, description="Optional line limit (default 500)"),
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or ~/... path to the file",
|
||||
),
|
||||
"offset": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Start reading from this line number (1-based). Omit to read from the top.",
|
||||
),
|
||||
"max_lines": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Maximum lines to return (default 500)",
|
||||
),
|
||||
},
|
||||
required=["path"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="project_file_list",
|
||||
description=(
|
||||
"List files and subdirectories within the Cortex project directory. "
|
||||
"Shows file sizes and modified timestamps. "
|
||||
"Project root: ~/agents_sync/projects/Cortex_and_Inara_dev/"
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or ~/... path to the directory",
|
||||
),
|
||||
},
|
||||
required=["path"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="file_stat",
|
||||
description=(
|
||||
"Get metadata for a file or directory: type, size, modified timestamp, line count (for text files) "
|
||||
"or entry counts (for directories). Use before reading to check recency or size. "
|
||||
"Restricted to the Cortex project directory."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or ~/... path to the file or directory",
|
||||
),
|
||||
},
|
||||
required=["path"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="file_grep",
|
||||
description=(
|
||||
"Search for a regex pattern in a file or directory, returning matching lines with surrounding "
|
||||
"context. Much more efficient than reading an entire source file — use this to find function "
|
||||
"definitions, variable names, TODO comments, imports, error strings, etc. "
|
||||
"Searches recursively by default. Capped at 50 matches. Skips binary files. "
|
||||
"Case-insensitive. Restricted to the Cortex project directory."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="File or directory to search (e.g. ~/agents_sync/projects/Cortex_and_Inara_dev/cortex/)",
|
||||
),
|
||||
"pattern": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Regex pattern to search for (case-insensitive). Examples: 'def ha_', 'import httpx', 'TODO'",
|
||||
),
|
||||
"context_lines": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Lines of context before/after each match (default 2, max 5)",
|
||||
),
|
||||
"recursive": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description="Search subdirectories recursively (default true)",
|
||||
),
|
||||
},
|
||||
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=(
|
||||
"Check the syntax of a Python (.py) or JSON (.json) file without executing it. "
|
||||
"Returns OK or the error with line number. "
|
||||
"Use after editing a file before restarting Cortex. "
|
||||
"Restricted to the Cortex project directory."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Path to the .py or .json file to check",
|
||||
),
|
||||
},
|
||||
required=["path"],
|
||||
),
|
||||
),
|
||||
# System-scoped
|
||||
types.FunctionDeclaration(
|
||||
name="file_read",
|
||||
description=(
|
||||
"Read a local file from the broader system (~/agents_sync/, ~/OSIT_dev/, ~/DgrZone_Nextcloud/, "
|
||||
"~/OSIT_Nextcloud/, Cortex home/). Supports offset for reading specific line ranges. "
|
||||
"For files within the Cortex project, prefer project_file_read instead. "
|
||||
"ADMIN ONLY."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or ~/... path to the file",
|
||||
),
|
||||
"offset": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Start reading from this line number (1-based)",
|
||||
),
|
||||
"max_lines": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Maximum lines to return (default 500)",
|
||||
),
|
||||
},
|
||||
required=["path"],
|
||||
),
|
||||
@@ -327,14 +717,18 @@ DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="file_list",
|
||||
description=(
|
||||
"List the files and subdirectories in a directory. "
|
||||
"Allowed paths: ~/agents_sync/, ~/OSIT_dev/, ~/DgrZone_Nextcloud/, ~/OSIT_Nextcloud/. "
|
||||
"List files and subdirectories from the broader system. "
|
||||
"Shows sizes and modified timestamps. "
|
||||
"Allowed: ~/agents_sync/, ~/OSIT_dev/, ~/DgrZone_Nextcloud/, ~/OSIT_Nextcloud/. "
|
||||
"ADMIN ONLY."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(type=types.Type.STRING, description="Absolute or home-relative path to the directory"),
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or ~/... path to the directory",
|
||||
),
|
||||
},
|
||||
required=["path"],
|
||||
),
|
||||
@@ -350,9 +744,18 @@ DECLARATIONS = [
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"path": types.Schema(type=types.Type.STRING, description="Absolute or home-relative path to write to"),
|
||||
"content": types.Schema(type=types.Type.STRING, description="Content to write"),
|
||||
"mode": types.Schema(type=types.Type.STRING, description="'overwrite' (default, replaces file) or 'append' (adds to end)"),
|
||||
"path": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Absolute or ~/... path to write to",
|
||||
),
|
||||
"content": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Content to write",
|
||||
),
|
||||
"mode": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="'overwrite' (default, replaces file) or 'append' (adds to end)",
|
||||
),
|
||||
},
|
||||
required=["path", "content"],
|
||||
),
|
||||
@@ -360,15 +763,18 @@ DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="session_read",
|
||||
description=(
|
||||
"Read a full session log by date (YYYY-MM-DD). Returns the complete conversation "
|
||||
"from that session — useful for continuity, recalling decisions, or reviewing "
|
||||
"what was discussed on a specific day. If the date is not found, lists available dates. "
|
||||
"Read a full conversation session log by date (YYYY-MM-DD). "
|
||||
"Useful for continuity and recalling past decisions. "
|
||||
"If the date is not found, lists available dates. "
|
||||
"Only reads this user's own sessions."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"date": types.Schema(type=types.Type.STRING, description="Date in YYYY-MM-DD format (e.g. '2026-05-08')"),
|
||||
"date": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Date in YYYY-MM-DD format (e.g. '2026-05-08')",
|
||||
),
|
||||
},
|
||||
required=["date"],
|
||||
),
|
||||
@@ -377,16 +783,20 @@ DECLARATIONS = [
|
||||
name="session_search",
|
||||
description=(
|
||||
"Search past conversation session logs for a keyword or phrase. "
|
||||
"Use this to recall what was discussed in previous sessions — "
|
||||
"e.g. 'what did we decide about X?', 'when did we set up Y?'. "
|
||||
"Returns matching excerpts with session dates, newest first. "
|
||||
"Only searches this user's own sessions."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"query": types.Schema(type=types.Type.STRING, description="Keyword or phrase to search for"),
|
||||
"limit": types.Schema(type=types.Type.INTEGER, description="Max results to return (default 5, max 20)"),
|
||||
"query": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Keyword or phrase to search for",
|
||||
),
|
||||
"limit": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Max results to return (default 5, max 20)",
|
||||
),
|
||||
},
|
||||
required=["query"],
|
||||
),
|
||||
|
||||
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",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
]
|
||||
277
cortex/tools/homeassistant.py
Normal file
277
cortex/tools/homeassistant.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Home Assistant tools — read device states and call services.
|
||||
|
||||
Credentials are read automatically from the current user's channels.json:
|
||||
"homeassistant": {"url": "https://ha.example.com", "token": "<long-lived-token>"}
|
||||
|
||||
Configure in Settings → Notifications → Home Assistant.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from google.genai import types
|
||||
|
||||
from auth_utils import get_user_channels
|
||||
from persona import get_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TIMEOUT = 10
|
||||
|
||||
# Attributes that are internal/noisy and not useful to show
|
||||
_SKIP_ATTRS = {
|
||||
"friendly_name", "icon", "entity_picture", "supported_features",
|
||||
"supported_color_modes", "color_mode", "min_color_temp_kelvin",
|
||||
"max_color_temp_kelvin", "min_mireds", "max_mireds",
|
||||
"assumed_state", "attribution",
|
||||
}
|
||||
|
||||
|
||||
def _get_ha_cfg() -> tuple[str, str]:
|
||||
"""Return (base_url, token) from the current user's channels.json."""
|
||||
channels = get_user_channels(get_user())
|
||||
ha = channels.get("homeassistant") or {}
|
||||
url = (ha.get("url") or "").rstrip("/")
|
||||
token = ha.get("token") or ""
|
||||
if not url or not token:
|
||||
raise ValueError(
|
||||
"Home Assistant not configured — add URL and token in Settings → Notifications."
|
||||
)
|
||||
return url, token
|
||||
|
||||
|
||||
def _auth(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _fmt_state(s: dict) -> str:
|
||||
"""Format a single HA state dict as a compact readable line."""
|
||||
entity_id = s.get("entity_id", "")
|
||||
state = s.get("state", "unknown")
|
||||
attrs = s.get("attributes", {})
|
||||
name = attrs.get("friendly_name", entity_id)
|
||||
|
||||
label = f"{name} ({entity_id})" if name != entity_id else entity_id
|
||||
useful = {k: v for k, v in attrs.items() if k not in _SKIP_ATTRS}
|
||||
|
||||
extra = ""
|
||||
if useful:
|
||||
parts = []
|
||||
for k, v in list(useful.items())[:6]: # cap at 6 attrs per entity
|
||||
parts.append(f"{k}: {v}")
|
||||
extra = " [" + ", ".join(parts) + "]"
|
||||
|
||||
return f"{label}: {state}{extra}"
|
||||
|
||||
|
||||
async def ha_get_state(entity_id: str) -> str:
|
||||
"""Return the current state and attributes of a single Home Assistant entity."""
|
||||
try:
|
||||
url, token = _get_ha_cfg()
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"{url}/api/states/{entity_id}", headers=_auth(token))
|
||||
|
||||
if resp.status_code == 404:
|
||||
return f"Entity not found: {entity_id}"
|
||||
if resp.status_code != 200:
|
||||
return f"HA API error {resp.status_code}: {resp.text[:400]}"
|
||||
|
||||
s = resp.json()
|
||||
attrs = s.get("attributes", {})
|
||||
lines = [
|
||||
f"**{attrs.get('friendly_name', entity_id)}** (`{entity_id}`)",
|
||||
f"State: **{s.get('state', 'unknown')}**",
|
||||
]
|
||||
changed = (s.get("last_changed") or "")[:19].replace("T", " ")
|
||||
if changed:
|
||||
lines.append(f"Last changed: {changed} UTC")
|
||||
|
||||
useful = {k: v for k, v in attrs.items() if k not in _SKIP_ATTRS}
|
||||
if useful:
|
||||
lines.append("Attributes:")
|
||||
for k, v in useful.items():
|
||||
lines.append(f" {k}: {v}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return f"Connection error: {e}"
|
||||
except Exception as e:
|
||||
logger.warning("ha_get_state error: %s", e)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
async def ha_get_states(domain: str = "", area: str = "") -> str:
|
||||
"""List HA entity states, optionally filtered by domain (e.g. 'light') or area name."""
|
||||
try:
|
||||
url, token = _get_ha_cfg()
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(f"{url}/api/states", headers=_auth(token))
|
||||
|
||||
if resp.status_code != 200:
|
||||
return f"HA API error {resp.status_code}: {resp.text[:400]}"
|
||||
|
||||
states = resp.json()
|
||||
|
||||
if domain:
|
||||
states = [s for s in states if s.get("entity_id", "").startswith(f"{domain}.")]
|
||||
if area:
|
||||
al = area.lower()
|
||||
states = [s for s in states
|
||||
if al in (s.get("attributes", {}).get("friendly_name") or "").lower()]
|
||||
|
||||
if not states:
|
||||
filters = [f"domain={domain}"] * bool(domain) + [f"area={area}"] * bool(area)
|
||||
return "No entities found" + (f" ({', '.join(filters)})" if filters else "")
|
||||
|
||||
lines = [f"{len(states)} entit{'y' if len(states) == 1 else 'ies'}:"]
|
||||
for s in sorted(states, key=lambda x: x.get("entity_id", "")):
|
||||
lines.append(_fmt_state(s))
|
||||
return "\n".join(lines)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return f"Connection error: {e}"
|
||||
except Exception as e:
|
||||
logger.warning("ha_get_states error: %s", e)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
async def ha_call_service(
|
||||
domain: str,
|
||||
service: str,
|
||||
entity_id: str = "",
|
||||
data: str = "",
|
||||
) -> str:
|
||||
"""Call a Home Assistant service (turn on/off lights, set thermostat, lock doors, etc.)."""
|
||||
try:
|
||||
url, token = _get_ha_cfg()
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
|
||||
payload: dict = {}
|
||||
if entity_id:
|
||||
payload["entity_id"] = entity_id
|
||||
if data:
|
||||
try:
|
||||
extra = json.loads(data)
|
||||
if isinstance(extra, dict):
|
||||
payload.update(extra)
|
||||
except json.JSONDecodeError:
|
||||
return f"Invalid JSON in data: {data}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
f"{url}/api/services/{domain}/{service}",
|
||||
headers=_auth(token),
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if resp.status_code not in (200, 201):
|
||||
return f"HA API error {resp.status_code}: {resp.text[:400]}"
|
||||
|
||||
changed = resp.json()
|
||||
if not changed:
|
||||
return f"✓ {domain}.{service} called (no state changes reported)."
|
||||
|
||||
lines = [f"✓ {domain}.{service} — {len(changed)} entity state(s) updated:"]
|
||||
for s in changed:
|
||||
lines.append(f" {s.get('entity_id', '')}: {s.get('state', '')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return f"Connection error: {e}"
|
||||
except Exception as e:
|
||||
logger.warning("ha_call_service error: %s", e)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="ha_get_state",
|
||||
description=(
|
||||
"Get the current state and attributes of a single Home Assistant entity. "
|
||||
"Use to check if a light is on, read a thermostat temperature, check a "
|
||||
"door/window sensor, battery level, HVAC mode, etc. "
|
||||
"entity_id format: domain.name — e.g. light.living_room, switch.garage, "
|
||||
"climate.ecobee, binary_sensor.front_door, sensor.outdoor_temp."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"entity_id": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Full entity ID, e.g. light.living_room or climate.ecobee_main",
|
||||
),
|
||||
},
|
||||
required=["entity_id"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="ha_get_states",
|
||||
description=(
|
||||
"List Home Assistant entity states, optionally filtered by domain or area. "
|
||||
"Use to survey what devices exist or check multiple entities at once. "
|
||||
"Domain examples: light, switch, sensor, climate, binary_sensor, lock, cover, "
|
||||
"media_player, input_boolean. Leave both blank to list everything (can be large)."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"domain": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Filter to this domain, e.g. 'light' or 'switch' (optional)",
|
||||
),
|
||||
"area": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Filter by area name substring match on friendly name (optional)",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="ha_call_service",
|
||||
description=(
|
||||
"Call a Home Assistant service to control a device or trigger an automation. "
|
||||
"Requires user confirmation before executing. Common examples: "
|
||||
"domain=light service=turn_on entity_id=light.living_room; "
|
||||
"domain=light service=turn_off entity_id=light.all; "
|
||||
"domain=switch service=toggle entity_id=switch.garage; "
|
||||
"domain=climate service=set_temperature data={\"temperature\":72}; "
|
||||
"domain=lock service=lock entity_id=lock.front_door; "
|
||||
"domain=script service=turn_on entity_id=script.goodnight."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"domain": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Service domain: light, switch, climate, lock, cover, script, automation, etc.",
|
||||
),
|
||||
"service": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Service name: turn_on, turn_off, toggle, set_temperature, lock, unlock, open, close, etc.",
|
||||
),
|
||||
"entity_id": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Target entity ID — omit for services that don't target a specific entity",
|
||||
),
|
||||
"data": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description='Extra service data as JSON string, e.g. {"temperature": 72, "hvac_mode": "heat"}',
|
||||
),
|
||||
},
|
||||
required=["domain", "service"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from google.genai import types
|
||||
from config import settings
|
||||
from persona import get_user
|
||||
@@ -77,6 +78,74 @@ async def web_push(title: str, body: str, url: str = "") -> str:
|
||||
return f"Push sent to {result['sent']} device(s) for {username} (pruned {result['pruned']} stale)."
|
||||
|
||||
|
||||
async def nc_talk_history(conversation_token: str = "", limit: int = 20) -> str:
|
||||
"""Read recent messages from a Nextcloud Talk conversation.
|
||||
|
||||
Requires nc_username and nc_app_password in channels.json under 'nextcloud'.
|
||||
conversation_token defaults to notification_room if not specified.
|
||||
"""
|
||||
from auth_utils import get_user_channels
|
||||
username = get_user()
|
||||
channels = get_user_channels(username)
|
||||
nct = channels.get("nextcloud", {})
|
||||
|
||||
url = nct.get("url", "").rstrip("/")
|
||||
nc_username = nct.get("nc_username", "").strip()
|
||||
nc_app_password = nct.get("nc_app_password", "").strip()
|
||||
token = conversation_token.strip() or nct.get("notification_room", "").strip()
|
||||
|
||||
if not url or not nc_username or not nc_app_password:
|
||||
return (
|
||||
"nc_talk_history requires nc_username and nc_app_password in channels.json "
|
||||
f"(under 'nextcloud'). Add these to home/{username}/channels.json to enable message reading."
|
||||
)
|
||||
if not token:
|
||||
return "No conversation token provided and no notification_room set in channels.json."
|
||||
|
||||
limit = min(max(int(limit), 1), 200)
|
||||
return await asyncio.to_thread(_sync_nc_talk_history, url, nc_username, nc_app_password, token, limit)
|
||||
|
||||
|
||||
def _sync_nc_talk_history(url: str, nc_user: str, nc_pass: str, token: str, limit: int) -> str:
|
||||
from datetime import datetime, timezone
|
||||
endpoint = f"{url}/ocs/v2.php/apps/spreed/api/v4/chat/{token}"
|
||||
try:
|
||||
resp = httpx.get(
|
||||
endpoint,
|
||||
params={"limit": limit, "lookIntoFuture": 0, "setReadMarker": 0, "noStatusUpdate": 1},
|
||||
auth=(nc_user, nc_pass),
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
timeout=15,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"NC Talk API error: {e}"
|
||||
|
||||
if resp.status_code != 200:
|
||||
return f"NC Talk API returned HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
|
||||
try:
|
||||
messages = resp.json().get("ocs", {}).get("data", [])
|
||||
except Exception as e:
|
||||
return f"Failed to parse NC Talk response: {e}"
|
||||
|
||||
if not messages:
|
||||
return "No messages found in this conversation."
|
||||
|
||||
# NC Talk returns newest-first; reverse to chronological order
|
||||
lines = [f"Last {len(messages)} messages from {token}:\n"]
|
||||
for msg in reversed(messages):
|
||||
sender = msg.get("actorDisplayName") or msg.get("actorId") or "Unknown"
|
||||
ts = msg.get("timestamp", 0)
|
||||
time_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
text = msg.get("message", "")
|
||||
if msg.get("messageType") == "system":
|
||||
lines.append(f"[system {time_str}] {text}")
|
||||
else:
|
||||
lines.append(f"{sender} ({time_str}): {text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def nc_talk_send(message: str) -> str:
|
||||
"""Send a message to the user via their configured notification channel.
|
||||
|
||||
@@ -145,4 +214,21 @@ DECLARATIONS = [
|
||||
required=["message"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="nc_talk_history",
|
||||
description=(
|
||||
"Read recent messages from a Nextcloud Talk conversation. Useful for checking "
|
||||
"what was said in a room before composing a reply, or reviewing recent context. "
|
||||
"Requires nc_username and nc_app_password in channels.json under 'nextcloud'. "
|
||||
"conversation_token defaults to notification_room if not provided."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"conversation_token": types.Schema(type=types.Type.STRING, description="NC Talk room token (defaults to notification_room from channels.json)"),
|
||||
"limit": types.Schema(type=types.Type.INTEGER, description="Number of messages to return (default 20, max 200)"),
|
||||
},
|
||||
required=[],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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."),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""
|
||||
Web tools — search (DuckDuckGo), direct HTTP fetch, and clean content extraction.
|
||||
Web tools — search (DuckDuckGo), direct HTTP fetch, clean content extraction, and HTTP POST.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from google.genai import types
|
||||
|
||||
from config import settings
|
||||
from persona import get_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,6 +118,80 @@ def _sync_web_read(url: str, max_chars: int) -> str:
|
||||
return f"Content from {url}:\n\n{text}"
|
||||
|
||||
|
||||
def _load_http_allowlist(username: str) -> list[str]:
|
||||
"""Load per-user HTTP POST allowlist (URL prefixes). Empty list = all blocked."""
|
||||
path = settings.home_root() / username / "http_allowlist.json"
|
||||
try:
|
||||
return [str(p).strip() for p in json.loads(path.read_text()) if str(p).strip()]
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("failed to read http_allowlist.json for %s: %s", username, e)
|
||||
return []
|
||||
|
||||
|
||||
def _http_post_allowed(url: str, allowlist: list[str]) -> bool:
|
||||
"""Return True if url starts with any allowlist entry (prefix match)."""
|
||||
for prefix in allowlist:
|
||||
if url.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def http_post(
|
||||
url: str,
|
||||
body: str = "",
|
||||
headers: dict | None = None,
|
||||
max_chars: int = 4096,
|
||||
) -> str:
|
||||
"""POST to an external URL. Requires the URL to match home/{user}/http_allowlist.json.
|
||||
|
||||
body may be a JSON string or plain text. If body is valid JSON, Content-Type is set
|
||||
to application/json; otherwise text/plain. Override via the headers param.
|
||||
Response is capped at max_chars (default 4096, max 131072).
|
||||
"""
|
||||
username = get_user()
|
||||
allowlist = _load_http_allowlist(username)
|
||||
if not allowlist:
|
||||
return (
|
||||
f"http_post blocked — no allowlist configured. "
|
||||
f"Add allowed URL prefixes to home/{username}/http_allowlist.json as a JSON array. "
|
||||
f"Example: [\"https://api.example.com\"]"
|
||||
)
|
||||
if not _http_post_allowed(url, allowlist):
|
||||
return (
|
||||
f"http_post blocked — {url} does not match any allowlist entry for {username}. "
|
||||
f"Add the URL prefix to home/{username}/http_allowlist.json."
|
||||
)
|
||||
|
||||
max_chars = min(max(int(max_chars), 100), 131072)
|
||||
|
||||
# Auto-detect content type from body
|
||||
body_str = body if isinstance(body, str) else json.dumps(body)
|
||||
try:
|
||||
json.loads(body_str)
|
||||
content_type = "application/json"
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
content_type = "text/plain"
|
||||
|
||||
req_headers = {"Content-Type": content_type}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
resp = await client.post(url, content=body_str.encode(), headers=req_headers)
|
||||
body_text = resp.text[:max_chars]
|
||||
truncated = len(resp.text) > max_chars
|
||||
suffix = f"\n\n[… truncated at {max_chars} chars]" if truncated else ""
|
||||
return f"HTTP {resp.status_code} {resp.url}\n\n{body_text}{suffix}"
|
||||
except httpx.HTTPError as e:
|
||||
return f"HTTP error: {e}"
|
||||
except Exception as e:
|
||||
logger.warning("http_post error for %s: %s", url, e)
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="web_search",
|
||||
@@ -169,4 +246,22 @@ DECLARATIONS = [
|
||||
required=["url"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="http_post",
|
||||
description=(
|
||||
"POST to an external URL. Requires the URL to match the user's http_allowlist.json. "
|
||||
"Use for calling webhooks, triggering automations, posting to APIs, or any HTTP action. "
|
||||
"body is a string — JSON or plain text are both accepted (Content-Type auto-detected). "
|
||||
"Override headers as needed. Response capped at max_chars (default 4096, max 131072)."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"url": types.Schema(type=types.Type.STRING, description="Full URL to POST to"),
|
||||
"body": types.Schema(type=types.Type.STRING, description="Request body — JSON string or plain text"),
|
||||
"max_chars": types.Schema(type=types.Type.INTEGER, description="Max response chars (default 4096, max 131072)"),
|
||||
},
|
||||
required=["url"],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Architecture: Planned Features
|
||||
|
||||
> What's next and how it's designed to work.
|
||||
> Last updated: 2026-04-29
|
||||
> Last updated: 2026-05-11
|
||||
|
||||
For the current task list see `TODO__Agents.md`. For phases and priorities see `ROADMAP.md`.
|
||||
|
||||
@@ -313,4 +313,232 @@ This pattern maps naturally to several existing concepts:
|
||||
2. Define the schema document — what goes in a wiki page, cross-reference format, category taxonomy
|
||||
3. Build an ingest tool/script that reads a source and updates wiki pages (LLM-driven)
|
||||
4. Build a lint cron job that health-checks the wiki periodically
|
||||
5. Consider Obsidian compatibility for human browsing of the wiki graph
|
||||
5. Consider Obsidian compatibility for human browsing of the wiki graph
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### Problem
|
||||
|
||||
`spawn_agent` currently grants sub-agents the full tool set of whatever role they're assigned. The spawning agent (Inara) cannot restrict a sub-agent to a subset of tools — the role config is the only gate. This means every spawned agent implicitly has access to everything the role allows, including potentially destructive operations (`shell_exec`, `file_write`, `cortex_restart`).
|
||||
|
||||
### Design
|
||||
|
||||
Add two optional parameters to `spawn_agent`: **`allow_tools`** and **`deny_tools`**.
|
||||
|
||||
- **`allow_tools`** — explicit allow list. If set, the sub-agent can *only* use tools in this list (intersected with what the role allows). If omitted, the role's full tool set is available.
|
||||
- **`deny_tools`** — explicit deny list. If set, these tools are removed from whatever the sub-agent would otherwise have access to. If omitted, nothing is denied beyond what the role already excludes.
|
||||
|
||||
**Effective tool set formula:**
|
||||
|
||||
```
|
||||
effective = (role_base_tools ∩ allow_tools) ∩ (role_base_tools \ deny_tools)
|
||||
```
|
||||
|
||||
Where `role_base_tools` is the full tool set the role config grants, `allow_tools` is the spawner's allow list (default: full set), and `deny_tools` is the spawner's deny list (default: empty set).
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```python
|
||||
# Research agent — web only, no file access, no shell
|
||||
spawn_agent(
|
||||
"Research the latest on Zigbee mesh repeaters",
|
||||
role="chat",
|
||||
allow_tools=["web_search", "web_read", "http_fetch"]
|
||||
)
|
||||
|
||||
# Code review — read-only, no shell
|
||||
spawn_agent(
|
||||
"Review this file for security issues",
|
||||
role="coder",
|
||||
deny_tools=["shell_exec", "file_write", "cortex_restart", "cortex_update"]
|
||||
)
|
||||
|
||||
# Full access (same as today — omit both params)
|
||||
spawn_agent("Refactor the auth module", role="coder")
|
||||
|
||||
# Narrow data migration — just file ops, no web
|
||||
spawn_agent(
|
||||
"Migrate the task files to the new format",
|
||||
role="coder",
|
||||
allow_tools=["file_read", "file_write", "file_list"]
|
||||
)
|
||||
```
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
**1. Model registry / role config — no changes needed.**
|
||||
|
||||
The role config (`role_cfg.get("tools")`) remains the authoritative ceiling. No schema changes at this level.
|
||||
|
||||
**2. `spawn_agent` function — new parameters + filtering logic.**
|
||||
|
||||
File: `cortex/tools/agents.py`. Add `allow_tools` and `deny_tools` as optional `list[str] | None` parameters. After resolving `tool_list` from `role_cfg.get("tools")`, apply the filter:
|
||||
|
||||
```python
|
||||
if allow_tools is not None:
|
||||
tool_list = [t for t in tool_list if t in allow_tools]
|
||||
if deny_tools is not None:
|
||||
tool_list = [t for t in tool_list if t not in deny_tools]
|
||||
```
|
||||
|
||||
**3. Declaration — update the Gemini `FunctionDeclaration`.**
|
||||
|
||||
Add `allow_tools` and `deny_tools` as optional parameters in the declaration so the orchestrator knows they exist.
|
||||
|
||||
**4. Confirmation gate behavior — explicit.**
|
||||
|
||||
If a sub-agent with restricted tools hits a confirmation gate (e.g., trying `shell_exec` with it denied), the gate blocks as normal — it does not silently fail. The sub-agent returns the "requires user confirmation" message as it already does.
|
||||
|
||||
### What Doesn't Change
|
||||
|
||||
- Existing `spawn_agent` calls with no `allow_tools`/`deny_tools` continue to work exactly as before
|
||||
- Role config remains the authoritative max — no security regression
|
||||
- No schema changes to `model_registry.json`
|
||||
- No UI changes needed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Architecture: Persona System & Memory
|
||||
|
||||
> How Inara (and other personas) know who they are and what they remember.
|
||||
> Last updated: 2026-04-03
|
||||
> Last updated: 2026-05-09
|
||||
|
||||
---
|
||||
|
||||
@@ -44,6 +44,19 @@ Each chat request specifies a tier (default: 2). Higher tiers load more context
|
||||
|
||||
`context_loader.py` assembles the system prompt from these files in order. The resulting prompt is passed to whichever LLM backend handles the request.
|
||||
|
||||
### System Block
|
||||
|
||||
Before any persona files, `context_loader.py` prepends a `--- System ---` block with per-request metadata:
|
||||
|
||||
```
|
||||
--- System ---
|
||||
Current date and time: Friday, 2026-05-09 at 02:34 PM EDT
|
||||
Current mode: Off The Record — this conversation is private and will not be logged or included in memory distillation
|
||||
```
|
||||
|
||||
The **date/time line** is always present (unless the role has `inject_datetime: false`).
|
||||
The **mode line** is only added when the session is Off The Record — normal Chat mode adds nothing, so the block stays minimal. This mirrors the same principle as the mode indicator in the UI: only signal when something non-default is in effect.
|
||||
|
||||
---
|
||||
|
||||
## Memory Distillation
|
||||
|
||||
@@ -71,9 +71,9 @@ Details: [`ARCH__BACKENDS.md`](ARCH__BACKENDS.md) | [`ARCH__PERSONA.md`](ARCH__P
|
||||
| `event_bus.py` | Internal SSE pub/sub (NC Talk → browser mirror) |
|
||||
| `email_utils.py` | SMTP invite emails |
|
||||
| `persona_template.py` | Bootstrap a new persona directory from templates |
|
||||
| `routers/` | One file per endpoint group — `chat`, `orchestrator`, `auth`, `files`, `ui`, `settings`, `local_llm`, `distill`, `audit`, `usage`, `push`, `help`, `onboarding`, `auth_google`, `nextcloud_talk`, `google_chat` |
|
||||
| `tools/` | Orchestrator tool implementations — `web` (search/fetch/web_read), `files` (file_read/write/session_read/search), `tasks`, `scratch`, `reminders`, `cron`, `system`, `notify`, `ae_journals`, `ae_tasks`, `agent_notes`, `agents` (spawn_agent) |
|
||||
| `static/` | Web UI — `index.html`, `app.js`, `style.css`, `login.html`, `setup.html`, `HELP.md`, `local_llm.html`, `settings.html` |
|
||||
| `routers/` | One file per endpoint group — `chat`, `orchestrator`, `auth`, `files`, `ui`, `settings`, `tools_settings`, `local_llm`, `distill`, `audit`, `usage`, `push`, `help`, `onboarding`, `auth_google`, `nextcloud_talk`, `google_chat`, `homeassistant` |
|
||||
| `tools/` | 58 orchestrator tools in 15 domain modules — `web`, `files` (project + system scope), `tasks`, `scratch`, `reminders`, `cron`, `system`, `notify`, `ae_knowledge`, `ae_tasks`, `agent_notes`, `agents`, `homeassistant`. Registry and access control in `tools/__init__.py`. |
|
||||
| `static/` | Web UI — `index.html`, `app.js`, `style.css`, `login.html`, `setup.html`, `HELP.md`, `local_llm.html`, `settings.html`, `notifications.html`, `tools_settings.html` |
|
||||
| `tests/` | pytest suite |
|
||||
|
||||
---
|
||||
@@ -94,6 +94,13 @@ Details: [`ARCH__BACKENDS.md`](ARCH__BACKENDS.md) | [`ARCH__PERSONA.md`](ARCH__P
|
||||
|
||||
**No single point of coupling** — tools live in `cortex/tools/`, separate from `ae_*` MCP tools. Channels live in `cortex/routers/`, each self-contained. Adding a channel or tool doesn't touch other subsystems.
|
||||
|
||||
**Tool access control (three layers):**
|
||||
1. **Role gate** (`TOOL_ROLES` in `tools/__init__.py`) — admin-only tools require `admin` role in `auth.json`.
|
||||
2. **Risk policy** (`home/{user}/tool_policy.json`) — `max_risk` auto-includes all tools at or below a level (low/medium/high); `whitelist`/`blacklist` override individual tools. Configurable at `/settings/tools`.
|
||||
3. **Model-level tool list** — per-role `tools` field in `local_llm.json`; can only restrict further, never elevate.
|
||||
|
||||
All 58 tools carry a `TOOL_RISK` rating (36 low / 12 medium / 10 high) used for auto-filtering. `CONFIRM_REQUIRED` is a separate static set of tools that trigger a user confirmation prompt before executing, independent of risk level.
|
||||
|
||||
**Agent private notes** — `AGENT_NOTES.md` per persona, writable only by the orchestrator via `agent_notes_*` tools. Never loaded into user-facing context. Three rolling backups (`bak1`–`bak3`) are visible read-only in the Files panel. Declared in `tools/agent_notes.py`; usage guidance in `PROTOCOLS.md`.
|
||||
|
||||
**No black boxes** — Every component, flow, and design decision is documented. Documentation is updated before implementation of significant changes and verified after. HELP.md is the user-facing contract; ARCH__*.md files are the developer contract; PROTOCOLS.md is the agent contract. If any of these drift from reality, that is a bug.
|
||||
|
||||
@@ -90,7 +90,8 @@ Stored in `home/{user}/model_registry.json`.
|
||||
"models": [
|
||||
{"id": "m1", "type": "claude_cli", "label": "Sonnet 4.6 (CLI)", "model_name": "claude-sonnet-4-6", "provider": "anthropic", "credential_id": "cli", "context_k": 1000, "tags": []},
|
||||
{"id": "m2", "type": "gemini_api", "label": "Gemini 2.5 Flash", "model_name": "gemini-2.5-flash", "provider": "google", "account_id": "a1b2", "context_k": 1000, "tags": []},
|
||||
{"id": "m3", "type": "local_openai", "label": "Gemma 4 E4B", "model_name": "gemma4:e4b", "provider": "local", "host_id": "h1", "context_k": 72, "tags": []}
|
||||
{"id": "m3", "type": "local_openai", "label": "Gemma 4 E4B", "model_name": "gemma4:e4b", "provider": "local", "host_id": "h1", "context_k": 72, "tags": []},
|
||||
{"id": "m4", "type": "local_openai", "label": "DeepSeek: V4 Flash", "model_name": "deepseek/deepseek-v4-flash", "provider": "local", "host_id": "h1", "context_k": 750, "reasoning_budget_tokens": 4096, "tags": ["frontier"]}
|
||||
],
|
||||
"roles": {
|
||||
"chat": {"primary": "m1", "backup_1": "m2", "backup_2": "m3"},
|
||||
@@ -109,6 +110,15 @@ Stored in `home/{user}/model_registry.json`.
|
||||
| `gemini_api` | Currently: Gemini CLI (gap — see Phase 4) | Should use google-genai SDK |
|
||||
| `local_openai` | HTTP to OpenAI-compatible endpoint | host_type controls path |
|
||||
|
||||
### Optional model fields
|
||||
|
||||
| Field | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `context_k` | int | 32 | Context window in thousands of tokens. Used for compaction budget (75% of window). |
|
||||
| `max_rounds` | int \| null | null | Per-model tool loop cap. `null` = use global `orchestrator_max_rounds`. Effective limit = `min(per_model, global)`. |
|
||||
| `tools` | bool | true | Whether this model supports tool calling. `false` = skip tool loop entirely; model gets a plain chat request. |
|
||||
| `reasoning_budget_tokens` | int \| null | null | Per-model reasoning/thinking budget for models that support it (e.g., DeepSeek V4 via OpenRouter). `null` = no reasoning override. When set, injected as `{"reasoning": {"budget_tokens": <value>}}` in the API call to OpenRouter-compatible endpoints. |
|
||||
|
||||
### Built-in model IDs
|
||||
|
||||
Always resolvable without a registry entry (used as `.env` role defaults):
|
||||
@@ -196,4 +206,4 @@ the orchestrator role can now be a local model.
|
||||
- Claude direct API key support (alternative to CLI OAuth)
|
||||
- OpenRouter as a named provider (already works as local host; could be promoted)
|
||||
- Per-role "test" button in role assignments UI
|
||||
- Per-user catalog additions (extend ANTHROPIC_CATALOG / GOOGLE_CATALOG from UI)
|
||||
- Per-user catalog additions (extend ANTHROPIC_CATALOG / GOOGLE_CATALOG from UI)
|
||||
@@ -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,22 +26,26 @@ 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 |
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
@@ -78,6 +82,7 @@ Cortex is a self-hosted personal AI platform. It routes messages from any input
|
||||
| [`CLAUDE.md`](../CLAUDE.md) | Project instructions for Claude Code — directory map, run commands, design decisions |
|
||||
| [`README.md`](../README.md) | Project root orientation, quick-start, user management |
|
||||
| [`cortex/static/HELP.md`](../cortex/static/HELP.md) | In-app help (rendered in UI for all users) |
|
||||
| [`SELF_UPDATE.md`](SELF_UPDATE.md) | Bootstrap for agents doing self-maintenance — git, Syncthing, scripts, doc checklist |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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.
|
||||
@@ -45,6 +45,11 @@
|
||||
- ✅ Sub-agent spawning — `spawn_agent` tool; per-host concurrency limit; Gemini API + local OpenAI backends
|
||||
- ✅ Web content extraction — `web_read` via trafilatura; strips ads/nav/boilerplate; 128K cap
|
||||
- ✅ Session log reader — `session_read(date)` tool; complements `session_search`
|
||||
- ✅ `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
|
||||
|
||||
144
documentation/SELF_UPDATE.md
Normal file
144
documentation/SELF_UPDATE.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Cortex — Self-Update & Maintenance Bootstrap
|
||||
|
||||
> A short reference for Inara (or any agent) performing maintenance, feature additions,
|
||||
> or configuration changes on the Cortex codebase.
|
||||
> Last updated: 2026-05-09
|
||||
|
||||
---
|
||||
|
||||
## Where the Code Lives
|
||||
|
||||
**Git repository:** `~/agents_sync/projects/Cortex_and_Inara_dev/`
|
||||
This is the canonical source. All Python, HTML, config templates, and documentation live here.
|
||||
|
||||
**Remote:** `ssh://git@git.dgrzone.com:2222/Scott.Idem/cortex-inara.git`
|
||||
|
||||
```bash
|
||||
git status # see uncommitted changes
|
||||
git log --oneline -8
|
||||
git push # push to Gitea after committing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Syncthing — How Code Gets to the Fleet
|
||||
|
||||
The `~/agents_sync/` directory syncs across all fleet machines in real time via Syncthing.
|
||||
Code is edited on `scott_lpt` (main laptop). Changes sync automatically to `scott-lt-i7-rtx`
|
||||
(the Agents Laptop, which runs the live Cortex service).
|
||||
|
||||
**You do not need to manually copy files.** Edit → Syncthing syncs → restart service.
|
||||
|
||||
**Sync is not instantaneous** — allow a few seconds after saving before restarting the service.
|
||||
|
||||
---
|
||||
|
||||
## Ignore Files
|
||||
|
||||
Two layers of ignores apply to this project:
|
||||
|
||||
| File | Scope | Purpose |
|
||||
|---|---|---|
|
||||
| `.gitignore` | Git | Keeps secrets, runtime data, and persona data out of the repo |
|
||||
| `.stignore` | Syncthing | Keeps machine-local artifacts from syncing (overlaps `.gitignore`) |
|
||||
| `~/agents_sync/.stignore` | Syncthing (root) | Fleet-wide Syncthing ignores (venvs, pyc, system files) |
|
||||
|
||||
**Key ignores to be aware of:**
|
||||
- `home/` — all persona data (memory, tasks, sessions, credentials). **Never in git.** Backed up via restic.
|
||||
- `cortex/.env` — secrets (API keys, JWT secret, VAPID keys). Never committed; `cortex/.env.default` is the template.
|
||||
- `cortex/.venv/` — Python virtualenv. Machine-local; recreated by `install.py`.
|
||||
- `cortex/data/` — runtime session JSON files. Machine-local.
|
||||
|
||||
---
|
||||
|
||||
## Helper Scripts
|
||||
|
||||
All scripts live in the project root. Run them from `scott_lpt`; they SSH to the service host as needed.
|
||||
|
||||
### `install.py` — Set up or update the service
|
||||
```bash
|
||||
python3 install.py # install / update (idempotent — safe to re-run)
|
||||
python3 install.py --check # status check only, no changes
|
||||
```
|
||||
What it does: creates `.venv`, installs `requirements.txt`, writes the systemd user service,
|
||||
enables linger, starts/restarts Cortex, checks LLM CLI auth, sets up the daily backup timer.
|
||||
|
||||
Run after: cloning the repo on a new machine, adding a new pip dependency, or changing the systemd service definition.
|
||||
|
||||
### `dev-restart.sh` — Restart the service and view logs
|
||||
```bash
|
||||
./dev-restart.sh # restart on scott-lt-i7-rtx, show last 30 log lines
|
||||
./dev-restart.sh logs # tail live logs (Ctrl-C to stop)
|
||||
./dev-restart.sh status # show service status only
|
||||
```
|
||||
This SSHes to `scott-lt-i7-rtx` — it does not restart anything locally.
|
||||
Run after: any Python file change.
|
||||
|
||||
### `backup.sh` — Back up persona data
|
||||
```bash
|
||||
./backup.sh # run a restic backup of home/ immediately
|
||||
```
|
||||
Normally runs automatically via systemd timer (daily 03:00). Run manually to verify backups
|
||||
or before a risky change to persona files. Backup location: `~/backups/cortex-home-restic`.
|
||||
|
||||
---
|
||||
|
||||
## Making a Change — Standard Workflow
|
||||
|
||||
1. **Read before writing.** Check `documentation/TODO__Agents.md` for active tasks.
|
||||
Check the relevant `ARCH__*.md` for the component you're changing.
|
||||
2. **Edit files** on `scott_lpt`. Syncthing handles distribution.
|
||||
3. **Syntax check** before restarting:
|
||||
```bash
|
||||
python3 -m py_compile cortex/<file>.py
|
||||
# or for all routers/tools at once:
|
||||
for f in cortex/routers/*.py cortex/tools/*.py; do python3 -m py_compile "$f" && echo "OK: $f"; done
|
||||
```
|
||||
4. **Restart:** `./dev-restart.sh` — confirm clean startup in the log output.
|
||||
5. **Update docs** — see checklist below.
|
||||
6. **Commit and push.**
|
||||
|
||||
---
|
||||
|
||||
## Documentation Update Checklist
|
||||
|
||||
Run through this after any feature or functionality change:
|
||||
|
||||
| Doc | Update when |
|
||||
|---|---|
|
||||
| `CLAUDE.md` | New tool, channel, router, tool count, major design change |
|
||||
| `cortex/static/HELP.md` | Any user-visible feature — tools, settings, UI, endpoints |
|
||||
| `documentation/TODO__Agents.md` | Mark completed items; add new planned work |
|
||||
| `documentation/MASTER.md` | New capability goes live; tool count changes |
|
||||
| `documentation/ROADMAP.md` | Phase items completed or added |
|
||||
| `documentation/ARCH__CHANNELS.md` | New channel, notification trigger, or scheduler job |
|
||||
| `documentation/ARCH__SYSTEM.md` | New module, router, or tools/ file |
|
||||
| `README.md` | Architecture diagram, channels table, or setup steps change |
|
||||
|
||||
The principle: **stale docs are bugs.** If a feature exists that docs don't mention, or docs
|
||||
describe something that doesn't exist, fix it before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Adding a Python Dependency
|
||||
|
||||
1. Add the package to `cortex/requirements.txt`
|
||||
2. Install it on the service host:
|
||||
```bash
|
||||
ssh scott@scott-lt-i7-rtx "~/agents_sync/projects/Cortex_and_Inara_dev/cortex/.venv/bin/pip install <package>"
|
||||
```
|
||||
3. Verify it works, then commit `requirements.txt`
|
||||
4. On any new machine setup, `install.py` will install it automatically from `requirements.txt`
|
||||
|
||||
---
|
||||
|
||||
## Key Paths on the Service Host (`scott-lt-i7-rtx`)
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `~/agents_sync/projects/Cortex_and_Inara_dev/` | Project root (synced from `scott_lpt`) |
|
||||
| `~/agents_sync/projects/Cortex_and_Inara_dev/cortex/.env` | Live secrets (not in git) |
|
||||
| `~/agents_sync/projects/Cortex_and_Inara_dev/home/` | All user persona data (not in git) |
|
||||
| `~/.config/systemd/user/cortex.service` | systemd service file (written by `install.py`) |
|
||||
| `~/backups/cortex-home-restic/` | Restic backup repository |
|
||||
| `~/.config/cortex/restic-password` | Restic encryption key — back this up separately |
|
||||
@@ -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.
|
||||
@@ -41,7 +41,9 @@ automatically. Remaining work is quality/reliability parity, not ground-up desig
|
||||
- [x] Context budget: `_context_budget()` uses `context_k * 1000 * 0.75`, min 16k — 2026-05-06
|
||||
- [x] Context compaction: `_compact_messages()` trims old tool results before each round and before the confirmation-gate call — 2026-05-06
|
||||
- [x] Error handling: malformed tool args caught + logged; tool execution errors returned as strings
|
||||
- [ ] Retry logic on transient API errors (connection timeout, 429, 503)
|
||||
- [x] Retry logic on transient API errors (connection timeout, 429, 503) — 2026-05-09
|
||||
- `_chat_with_retry()` helper in `openai_orchestrator.py`; 3 attempts, exponential backoff (1s, 2s)
|
||||
- Retries on `APIConnectionError` and `APIStatusError` with status 429/500/502/503/504
|
||||
- [ ] Test end-to-end with Gemma 4 E4B and 26B A4B on scott_gaming
|
||||
- [ ] Review `ARCH__FUTURE.md` agent architecture ideas before finalising design
|
||||
- Reference: `docs/OPEN_WEBUI_API.md`, `documentation/ARCH__FUTURE.md` §1
|
||||
@@ -65,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.
|
||||
|
||||
@@ -87,17 +142,26 @@ 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
|
||||
- [ ] **`http_post`** — POST to external URLs
|
||||
- Params: `url: str`, `body: dict | str`, `headers: dict | None`
|
||||
- Per-user host allowlist in `home/{user}/http_allowlist.json` (same pattern as email)
|
||||
- Default: blocked unless URL host matches an allowlist entry
|
||||
- Confirm-required for safety
|
||||
- [ ] **`nc_talk_history`** — read recent Talk messages before replying
|
||||
- Params: `conversation_token: str`, `limit: int = 20`
|
||||
- Returns last N messages with sender + timestamp
|
||||
- Admin-only (requires NC Talk API credentials from channels.json)
|
||||
- [ ] **`http_post`** — POST to external URLs with allowlist
|
||||
- [ ] **`task_list` priority filter** — add `priority` param alongside existing `status`
|
||||
- [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)
|
||||
- Default: blocked if no allowlist or URL doesn't match any prefix
|
||||
- Admin-only, confirm-required
|
||||
- [x] **`nc_talk_history`** — read recent Talk messages — 2026-05-09
|
||||
- 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`
|
||||
- [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
|
||||
@@ -115,16 +179,37 @@ Inara reaches out on her own initiative via NC Talk, Google Chat, email, or brow
|
||||
- [x] `POST /api/push/test` + `POST /api/push/reminders/check` — on-demand test endpoints
|
||||
- [x] `push_utils.py` — fixed `pywebpush` 2.x key deserialisation (use `Vapid.from_pem()` instead of passing PEM string)
|
||||
|
||||
### [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
|
||||
### [Channel] Home Assistant integration — design & tools
|
||||
Inara can already receive HA events via `POST /webhook/ha/{username}/{webhook_id}` and
|
||||
respond via web push. Next steps are deciding what events to send and giving Inara the
|
||||
ability to act on HA via the REST API.
|
||||
|
||||
- [ ] **Event design** — decide which HA events are worth routing to Inara (security,
|
||||
climate thresholds, low battery, unexpected device state). Avoid flooding with
|
||||
high-frequency sensor polling. Per-automation `"tools": true/false` to choose
|
||||
notify-only vs. agentic response.
|
||||
- [ ] **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.
|
||||
- [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`
|
||||
- [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
|
||||
- [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 ✅ — 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
|
||||
@@ -139,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
|
||||
@@ -195,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
|
||||
@@ -449,3 +551,14 @@ other based on resources and specialisation. No central coordinator required.
|
||||
- FastAPI service with streaming SSE response
|
||||
- Claude CLI and Gemini CLI subprocess backends
|
||||
- Session context management (rolling window, `MAX_HISTORY_MESSAGES`)
|
||||
|
||||
|
||||
### [Tools] Orchestrator tool expansions — Round 3
|
||||
|
||||
- [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
|
||||
- [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