Compare commits

...

54 Commits

Author SHA1 Message Date
Scott Idem
e8819773ee docs: update TODO — mark completed items from 2026-06-03 session
- aider_run async/notify: done
- L2→L3 boundary enforcement: done (default _agent_level=2)
- aider_run multi-provider credentials: done
- Added remaining item: pass _agent_level=1 from main orchestrators

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:06:47 -04:00
Scott Idem
0c1cf3989a feat: aider multi-provider credentials + test suite green (182/182)
aider_run multi-provider credentials (tools/aider.py):
- _resolve_credentials() — general credential resolver; replaces the previous
  OpenRouter-only injection; resolution priority: Anthropic model hint → explicit
  host_label → model prefix (openrouter/*, groq/*, deepseek/*, …) → OpenRouter
  default → Anthropic API key → any keyed cloud host → local/generic host
- _host_flags() — generates --api-key slug=key for known cloud providers (OpenRouter,
  OpenAI, Groq, Together, Fireworks, X.ai, DeepSeek, Mistral); generates
  --openai-api-base + --openai-api-key for generic/local hosts (Open WebUI, Ollama);
  appends /api suffix for openwebui host_type; auto-prefixes model with 'openai/'
  for generic endpoints when model has no / prefix
- Anthropic API keys from providers.anthropic.credentials (not a host entry)
- host_label param added to aider_run and FunctionDeclaration — pick a configured
  host by partial label match (e.g. 'OpenRouter', 'Local', 'scott-lt-i7-rtx')
- 16 unit tests for _resolve_credentials covering all resolution paths

main.py: move @app.get("/health") before app.include_router(ui.router) — the
/{username} catch-all in ui.router was swallowing the /health path

Test suite: 37 pre-existing failures → 182/182 passing
- test_tools.py: _task_list() missing priority arg (6 callsites); cron ID regex
  c_\w+ → c_[\w-]+ (token_urlsafe includes '-', causing intermittent truncation)
- test_webhooks.py: rewritten for per-user channel config architecture —
  patch routers.nextcloud_talk/google_chat.get_user_channels instead of removed
  settings fields; corrected endpoints /webhook/nextcloud/scott and
  /channels/google-chat/scott; non-empty cfg dicts so falsy-guard passes
- test_health.py: test_unknown_route_404 now uses 3-segment path (/{u}/{p}/x)
  since single-segment paths hit the /{username} UI catch-all
- test_api_files.py: removed '../config.py' from not-in-allowed test (ASGI
  normalizes it to /config.py which hits /{username} catch-all, not files router)
- test_security.py: same webhook patch target fix; per-user endpoint URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:00:45 -04:00
Scott Idem
658c508925 feat: multi-level agent management — background agents, lifecycle tools, 3-level hierarchy
agent_manager.py (new):
- AgentRecord dataclass: agent_id, level (1/2/3), role, task, status, started,
  parent_id (lineage), finished, result, notify, _task_ref
- register() / finish() / cancel_agent() / list_agents() / get() / set_task_ref()
- Calls notification.notify() on completion when notify=True (same channel as
  reminders and cron completions)
- 24-hour pruning of completed records on each new registration

spawn_agent (tools/agents.py):
- background=True: fires asyncio.create_task(), registers in agent_manager, returns
  agent_id string immediately — sync path unchanged (no regression)
- notify=True: push/Talk notification when the background task completes
- Level enforcement: _agent_level param tracks hierarchy depth; when spawning from
  Level 2, child automatically gets spawn_agent + aider_run denied so Level 3 agents
  cannot delegate further

New lifecycle tools (tools/agents.py + __init__.py):
- agent_status(agent_id) — status, role, level, elapsed, task, result preview; user-level
- agent_list(status, limit) — all agents for current user, newest first; user-level
- agent_cancel(agent_id) — kills background task; admin-only, confirm-required

tests/test_agent_manager.py (new, 41 tests):
- agent_manager CRUD, pruning, notification hook
- spawn_agent background: returns immediately, completes async, timeout, failure
- Level enforcement: L1→L2 permits spawn, L2→L3 auto-denies; explicit tool_list path
- agent_status / agent_list / agent_cancel output formatting
- aider_run background: returns agent_id, completes async, sync path unchanged
- All tests run without browser or Cortex service (~2.5s total)
  Run: cd cortex && .venv/bin/python -m pytest tests/test_agent_manager.py -v

Docs: ARCH__FUTURE.md §13 (full design), ROADMAP.md, TODO__Agents.md, MASTER.md,
HELP.md (orchestrator description corrected, tool schema line updated to reflect
keyword routing), CLAUDE.md tool count 66→69.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:40:20 -04:00
Scott Idem
29d8aa4aae feat: tool schema optimization, keyword routing, aider_run coding agent
Tool schema optimization (PLAN__Tool_Schema_Optimization.md Phases 1-3):
- model_registry.py: ROLE_DEFAULT_TOOLS — distill gets [], research/coder get
  narrow tool lists by default; applied in get_role_config() when user hasn't
  configured a custom list
- openai_orchestrator.py: keyword routing via narrow_tools_by_keywords() — scans
  user message + last assistant turn; narrows active schemas to matched categories
  only (e.g. "weather" → 3 web tools instead of 69); zero tools sent for pure chat
- openai_orchestrator.py: _get_cached_tools() — module-level schema cache keyed by
  (role, sorted_tool_list, risk_params); eliminates redundant schema rebuilds
- openai_orchestrator.py: _TOOL_SCHEMA_OVERHEAD 3000 → 500 tokens (schemas now
  excluded from the per-call fixed estimate since they're cached separately)
- tools/__init__.py: CATEGORY_TOOL_MAP + _KEYWORD_CATEGORY_MAP + classify_tool_categories()
  + narrow_tools_by_keywords() — the classifier logic lives here so both orchestrators
  can share it

aider_run tool (cortex/tools/aider.py):
- Invokes Aider as a subprocess with --message --yes-always --no-pretty --no-stream
- Project aliases: cortex / aether_api / aether_frontend / aether_container
- Auto-injects OpenRouter API key from Cortex model registry (no ~/.env needed)
- background=True fires async + registers in agent_manager; notify=True sends push
  notification on completion
- admin-only, confirm-required, TOOL_RISK=high
- .gitignore: added .aider.chat.history.md / .aider.input.history / .aider.llm.history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:39:44 -04:00
Scott Idem
29940c299b docs: fix Chat and Tools section accuracy in HELP.md
- Copy button appears on all messages (user + assistant), not just assistant
- Delete requires a confirm step — clarified in description
- Model tag position is below-left for assistant messages, not bottom-right
- Tool calls render as expandable per-call cards, not a single  N summary note
- help.html: default-open sections updated to Getting Started / Chat / Sessions / Model Registry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:36:24 -04:00
Scott Idem
105ff8507f fix: restore proper tab button CSS in help.html (browser-reset issue with Tailwind preflight: false)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:19:08 -04:00
Scott Idem
c2a12a895a docs: rename Backends → Switching Models in HELP.md; fix stale Backup 2 reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:14:55 -04:00
Scott Idem
df1f358912 docs: fix role slots description in HELP.md (Primary + Backup 1, not Backup 2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:11:03 -04:00
Scott Idem
7a27190ffe feat: custom roles, Tailwind settings pages, pg.css fixes, doc cleanup
Model Registry:
- Add per-user custom roles (add/remove via UI); required roles chat/orchestrator/distill
  are always present and cannot be removed
- Auto-migrate legacy .env-defined roles to custom_roles on first access
- Role config panel (gear): Remove role button moved inside panel; required badge below name
- Role select: Primary + Backup slots only (was three)

Settings pages — Tailwind CSS migration (CDN, preflight: false):
- local_llm.html, settings.html, help.html, notifications.html, tools_settings.html,
  crons.html, integrations.html all migrated; pg-* color tokens; dark/light via data-theme

pg.css fixes:
- input[type=checkbox/radio]: width: auto — prevents pg.css width:100% from stretching checkboxes
- btn-submit: responsive sizing via Tailwind w-full md:w-96 on each button (no longer full-width on desktop)

Documentation:
- MASTER.md, TODO__Agents.md: remove "/ Inara" from titles; description updated to "per-user AI personas"
- HELP.md: persona-agnostic language throughout (NC Talk, Google Chat, push, schedules, HA sections);
  roles section restructured to show required vs. custom roles with examples
- notifications.html: subtitle and HA description use "your persona" not "Inara"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:03:11 -04:00
Scott Idem
070f1ce156 fix: align settings/models nav and heading with all other settings pages
local_llm.html had a stale nav (hardcoded / and /help, missing Notifications /
Tools / Schedules / Integrations links) and used a different heading markup
(.page-header) than the rest of the settings pages (.page-title/.page-subtitle).

- Add pg.css link; strip duplicate base CSS (vars, reset, body, nav) from inline
  <style> — page-specific styles (provider blocks, model rows, roles, badges) kept
- Nav: use {{ back_href }} / {{ help_href }} / {{ integrations_nav }} placeholders;
  add Notifications, Tools, Schedules, Integrations (admin) links
- Heading: .page-header → <h1 class="page-title"> + <p class="page-subtitle">
- Body structure: move <nav> outside the .page wrapper; rename .page → .page-wrap
- local_llm.py: _render() accepts request, injects back_href / help_href /
  integrations_nav; adds _preferred_persona(), _integrations_nav() helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:51:06 -04:00
Scott Idem
a92fd90f0d feat: Anthropic SDK backend — API key alternative to Claude CLI OAuth
Adds `anthropic_api` model type so users can authenticate with a direct
Anthropic API key instead of (or alongside) the CLI OAuth session.

- model_registry.py: `anthropic_api` type; `save/get/remove_anthropic_api_key()`
  mirroring the Google account pattern; `save_cloud_model()` now picks type
  based on credential type (cli → claude_cli, api_key → anthropic_api);
  `_resolve_model()` merges api_key from the credential entry
- llm_client.py: `_anthropic_api()` backend (AsyncAnthropic SDK); dispatch
  and fallback wiring; usage tracking
- routers/local_llm.py: Anthropic API key management routes
  (POST /settings/local/anthropic-key, /anthropic-key/{id}/remove);
  `anthropic_api` badge and edit-form credential selector
- static/local_llm.html: Anthropic Cloud Provider block now shows API key
  management (add/remove); Add Model → Anthropic tab has credential selector
  (CLI vs API key)
- requirements.txt: enable anthropic>=0.40.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:30:56 -04:00
Scott Idem
70665fadff feat: schedules UI, task cron type, monthly/yearly schedules, AE DB tools, integrations page
- Schedules web UI (/settings/crons): list, add, edit, pause/resume, delete jobs
- cron task type: full orchestrator tool loop on a schedule, result → notification channel
- parse_schedule: monthly/yearly formats (monthly:DD:HH:MM, yearly:MM:DD:HH:MM)
- HA inbound webhook tools toggle: orchestrator loop vs. direct LLM, configurable in UI
- ae_db_query/describe/show_view: SELECT-only Aether MariaDB access (admin, per-user creds)
- /settings/integrations: admin-only page for Aether DB credentials
- Schedules nav link added to all settings pages
- pymysql added to requirements
- Docs updated: HELP.md, MASTER.md, CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:06:43 -04:00
Scott Idem
96b3c796c5 feat: file attachment support in chat (images + text/code files)
Text files (.md, .py, .js, .json, etc.): read client-side and injected
into the message body as a fenced code block — works with all backends
with zero model capability requirements.

Images (PNG/JPG/WebP/GIF, max 5 MB): encoded as base64 data URL on the
client and sent as a separate attachment field. Backend formats them as
OpenAI multimodal content (text + image_url) for local_openai backends.
Claude CLI and Gemini CLI see the text message with a "📎 filename.png"
note; image data is never written to session history.

- index.html: 📎 button + hidden file input in mode-select row;
  attachment-row preview area with thumbnail (images) or filename chip
- app.js: _resolveAttachment(), file reader, clearAttachment();
  sendMessage/sendOrchestrate updated to allow no-text sends when a
  file is pending; attachment spread into chat payload for images
- chat.py: Attachment model; attachment field on ChatRequest;
  llm_attachment extracted in _stream_chat and passed to complete()
- llm_client.py: attachment param through complete()/_dispatch()/_local();
  _local() builds multimodal content array for vision calls
- style.css: #attach-btn, #attachment-row, #attachment-preview, thumb

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:46:50 -04:00
Scott Idem
50c1997e91 docs: mark Phase 2/3 done; add file_diff, git tools, spawn_agent restrictions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:34:26 -04:00
Scott Idem
3716e5974f feat: Phase 3 model toggle — cycle chat-role slot models in UI
Replaces the role-cycle toggle with a slot model toggle in the Context &
Memory panel. The active model label is shown on the button; clicking cycles
through Primary → Backup 1 → Backup 2 slots configured for the Chat role.

- app.js: remove activeRole()/availableRoles role-cycling; add
  activeChatModel()/chatModels slot cycling; update send/orchestrate
  payloads to send slot + chat_role:"chat"; fix updateSendBtnTitle and
  startRunTimer to use activeChatModel()
- chat.py: add slot field to ChatRequest; pass slot= to complete();
  resolve backend_label from slot config; add _chat_slot_models() helper;
  include chat_models in GET /backend response
- HELP.md: update Model toggle description, tool count (62/16),
  Backends section, API chat payload example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:32:43 -04:00
Scott Idem
85e13314a2 feat: add confirm step to message-level delete
Clicking del now shows 'confirm delete / cancel' inline in the action
bar. Cancel rebuilds the original buttons; confirm proceeds as before.
Matches the session delete pattern added in the prior commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:20:13 -04:00
Scott Idem
20f3fe4f71 docs: mark completed items in TODO__Agents.md
task_list priority filter, session delete confirm, spawn_agent tool
restrictions, HA API tools/config/confirm-required, file_diff,
git_status/log/diff — all completed 2026-05-12.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:17:42 -04:00
Scott Idem
f336ae9687 feat: task_list priority filter, session delete confirm, spawn_agent tool restrictions
- task_list: add priority param ('low'/'normal'/'high') alongside existing status filter
- Session delete: inline confirm row (Delete / Cancel) instead of immediate delete
- spawn_agent: allow_tools and deny_tools per-call params; role config remains ceiling;
  deny_tools falls back to confirm_deny gate when no explicit tool_list is set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:09:50 -04:00
Scott Idem
76fef827c5 docs: update tool count to 62 and current state to 2026-05-12
Reflects file_diff and git_status/log/diff additions, pg.css refactor,
and reasoning level controls added this session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 00:14:12 -04:00
Scott Idem
b7144d5903 feat: add git_status, git_log, git_diff orchestrator tools
Read-only wrappers around git commands, project-scoped. Covers working
tree status, commit history browsing (with optional path filter), and
diffs between refs or the working tree — cleaner than shell_exec for
code review and change verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 00:12:00 -04:00
Scott Idem
3c9b8f5909 feat: add file_diff orchestrator tool
Runs diff -u on two project-scoped files. Low risk, no admin required.
Covers code review, config comparison, and before/after verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 00:08:36 -04:00
Scott Idem
54eef73b74 docs: add spawn_agent per-invocation tool restriction design (ARCH__FUTURE §12)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:23:17 -04:00
Scott Idem
4c3d9a7a65 refactor: extract shared settings stylesheet (pg.css) and clean up inline styles
- Create cortex/static/pg.css with shared variables, nav, containers, form
  elements, button classes, text utilities, and feedback messages
- All four settings pages (settings, notifications, tools, help) now link to
  pg.css and have page-specific-only <style> blocks
- Style block line counts reduced: settings 244→70, notifications 189→42,
  tools 126→88, help 122→75
- Inline style= attributes reduced: settings 45→4, notifications 7→3,
  tools 12→4, help 0
- Introduced shared CSS classes: .btn-submit, .btn-save, .btn-cancel,
  .btn-secondary, .action-link, .hint, .section-note, .page-title,
  .page-subtitle, .page-wrap, .usage-table, .tool-cat-row
- Fix tools_settings.py: replace server-generated inline styles on category
  header rows with .tool-cat-row CSS class
- Fix settings.py: add btn-save/btn-cancel/persona-rename-cancel classes to
  server-rendered persona rename form buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:22:59 -04:00
Scott Idem
8ab1942514 refactor: migrate Tool Permissions from Settings to /settings/tools
- Remove Tool Permissions form from settings.html; replace with a
  "Tool Settings →" link that redirects to /settings/tools
- Add Confirmation Gate section to tools_settings.html (allow/deny
  textareas) inside the same form as risk policy — one save covers all
- tools_settings.py save handler now writes allow/deny alongside
  max_risk/whitelist/blacklist into tool_policy.json
- Remove /settings/tool-policy POST route from settings.py (no longer needed)
- Remove get_tool_policy, save_tool_policy, CONFIRM_REQUIRED imports
  from settings.py (now owned by tools_settings.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:50:48 -04:00
Scott Idem
69ec2f667d feat: tool risk policy UI + wiring through all orchestrators
- New /settings/tools page: max_risk selector (low/medium/high) + per-tool
  override dropdowns (Default / Force include / Force exclude) for all 58 tools
  grouped by category with color-coded risk badges; JS updates Auto status live
- get_tools_for_role() + get_openai_tools_for_role() now accept max_risk,
  whitelist, blacklist; _apply_risk_policy() handles the filtering logic
- get_risk_policy() helper in auth_utils reads from tool_policy.json
- Risk policy wired through orchestrator.py, openai_orchestrator.py,
  orchestrator_engine.py, nextcloud_talk.py, homeassistant.py
- Tools nav link added to settings.html and notifications.html
- CLAUDE.md and ARCH__SYSTEM.md updated: tool count 50→58, risk system docs,
  tool access control three-layer model documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:45:04 -04:00
Scott Idem
c9c1ca7de6 feat: TOOL_RISK ratings for all 58 orchestrator tools
Add informational security risk ratings (low/medium/high) to every tool.
Groundwork for future auto-allow tiers (max_risk + whitelist + blacklist).
Breakdown: 36 low, 12 medium, 10 high.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:32:22 -04:00
Scott Idem
ac06b3bc7b feat: project-scoped file tools — grep, stat, syntax_check, offset reads
Add five project-scoped tools (user-level, no admin required):
  project_file_read — read with 1-based offset for paging large files
  project_file_list — list with sizes + timestamps
  file_stat         — size, modified time, line count / entry count
  file_grep         — regex search with context lines, up to 50 matches
  file_syntax_check — py_compile (.py) or json.loads (.json)

Also add offset support to existing file_read (system scope).
Rename "Files" tool category to "System Files"; add "Project Files" category.
Project scope restricted to Cortex_and_Inara_dev/ project root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:23:50 -04:00
Scott Idem
fc6600c33e feat: Home Assistant API tools (ha_get_state, ha_get_states, ha_call_service)
Register three HA orchestrator tools so Inara can read device states and
control devices via the HA REST API. ha_call_service requires admin role
and user confirmation. Also includes accumulated UI fixes (setProcessing
helper, wasNewSession flag cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:39:35 -04:00
Scott Idem
ba91de37c5 feat: Home Assistant settings UI + fix channels.json
notifications.html: add Home Assistant section with two collapsible
blocks — Connection (HA URL + Long-Lived Access Token) and Inbound
webhook (webhook ID with endpoint URL hint showing the username).
Token field uses keep-existing pattern (blank = no change).

settings.py: wire ha_url, ha_token, ha_webhook_id through
_notifications_page() template substitution and save_notifications()
POST handler. Preserves existing HA config fields (persona, tier,
role, tools) on save.

TODO__Agents.md: add Home Assistant integration planning section
(event design, richer payload template, HA API tools).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:18:45 -04:00
Scott Idem
1d361fe809 feat: NCT orchestrator support + Home Assistant webhook
nextcloud_talk.py:
- Fix missing import hmac / import hashlib (NameError bug in _verify_signature)
- Add orchestrator routing when channels.json "tools": true — sends
  " Working on it…" immediately, then runs the full tool loop and
  replies with the result; checkpoint case gets a web UI confirmation note
- Read tier and role from channel config (defaults: default_tier / "chat")
- Pass cfg through to _process_message

homeassistant.py (new):
- POST /webhook/ha/{username}/{webhook_id}
- Auth: webhook_id path segment matched against channels.json
- Accepts JSON or form-encoded body from HA automations
- Builds natural-language task from payload (uses "message" key if present,
  otherwise serialises full body as context)
- Same orchestrator/direct dispatch as NCT
- Delivers response via notify() — NC Talk, web push, or configured channel
- Session key: ha_{username} for continuity across HA events
- Registered in main.py; /webhook/ prefix already public in auth_middleware

channels.json schema addition:
  "homeassistant": {
    "webhook_id": "your-secret-id",
    "persona": "inara",
    "tier": 2,
    "role": "chat",
    "tools": false
  }

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 19:45:59 -04:00
Scott Idem
19d6f004ed feat: reasoning level select (Off/Light/Moderate/High/Max)
Replace free-form reasoning_budget_tokens number input with a 5-level
select in both the edit form (local_llm.py) and add-model form
(local_llm.html). Values: 0 / 1024 / 4096 / 8192 / 32768 tokens.
Edit form pre-selects the stored value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:44:20 -04:00
Scott Idem
a66c5a7f84 feat: reasoning token budget + session name in header
- reasoning_budget_tokens: optional int field on local_openai models;
  when set, injects {"reasoning": {"budget_tokens": N}} via extra_body
  into every OpenRouter API call (both tool-loop and confirmation-gate
  rounds). Field exposed in the model edit form in Settings.

- session name moved from standalone full-row div between #messages
  and #input-area into the persona-switcher block in the header, as a
  third dim line under "Cortex · Local". Collapses when empty via
  :empty CSS. No JS changes required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:35:23 -04:00
Scott Idem
85792a7bcf feat: per-role inject_mode, OTR fixes, hover metadata, send/stop tooltip
- inject_mode: per-role toggle (parallel to inject_datetime) gates the
  "Current mode: Off The Record" line in the system prompt; wired through
  model_registry, context_loader, chat router, orchestrator router, and
  local_llm settings UI

- OTR orchestrator fix: OrchestrateRequest now carries off_record;
  _finalize_job stores it per message and gates log_turn on it; JS
  orchestrate payload sends off_record correctly

- Per-message hover metadata: removed always-visible .model-tag; replaced
  with .msg-meta strip in the action bar (hover-only); shows model label,
  host, fallback indicator, and OTR badge; stored in session JSON

- Send/stop button tooltip: shows role + model and (when tools on)
  separate orchestrator model + engine label; live elapsed timer on stop
  button via startRunTimer/stopRunTimer

- OrchestratorResult.backend_label: new field; openai_orchestrator fills
  it; finalize_job propagates it to job dict and session messages

- GET /backend: exposes orchestrator_model label so the frontend tooltip
  can show both models separately

- TODO: session delete confirmation added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:12:03 -04:00
Scott Idem
0afa135ce9 docs: document System block and OTR mode in ARCH__PERSONA.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:40:21 -04:00
Scott Idem
128d8a7c1e feat: inject session mode into persona system prompt
context_loader.load_context() now accepts a mode param ("chat"|"otr").
In OTR mode, the --- System --- block gains a second line:

  Current mode: Off The Record — this conversation is private
  and will not be logged or included in memory distillation

routers/chat.py passes mode="otr" when req.off_record is True.
Normal chat and all orchestrator calls stay at mode="chat" (no change
to the System block). The System block consolidates date/time and mode
in one place, matching the existing timestamp pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:35:09 -04:00
Scott Idem
3a4f518300 docs: add SELF_UPDATE.md — agent self-maintenance bootstrap
Short reference covering: git repo location, Syncthing fleet sync,
ignore files (.gitignore / .stignore), helper scripts (install.py,
dev-restart.sh, backup.sh), standard change workflow, doc update
checklist, pip dependency process, and key paths on the service host.
Linked from MASTER.md document map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:00:28 -04:00
Scott Idem
348ca120c1 feat: full channels.json UI + http_allowlist settings
Notifications page:
- NC Talk section expanded: url, bot_secret, notification_room,
  nc_username, nc_app_password — all fields from channels.json now editable
- Per-channel sections use <details>/<summary> collapsibles; auto-open
  when values are present
- Secrets use type=password with "leave blank to keep" semantics
- Google Chat outbound webhook in its own collapsible section

Account settings:
- HTTP POST Allowlist section added (same textarea pattern as email allowlist)
- POST /settings/http-allowlist route saves home/{user}/http_allowlist.json
- Example placeholder shows ha.dgrzone.com and n8n patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:57:18 -04:00
Scott Idem
7b443b40a4 feat: http_post tool, nc_talk_history tool, local orchestrator retry
- http_post: POST to external URLs with per-user URL prefix allowlist
  (home/{user}/http_allowlist.json); admin-only, confirm-required
- nc_talk_history: read recent NC Talk messages via Basic Auth (requires
  nc_username + nc_app_password in channels.json under nextcloud)
- openai_orchestrator: _chat_with_retry() wraps both API calls with
  exponential backoff (3 attempts, 1s/2s) on connection errors and
  transient status codes (429, 500, 502, 503, 504)
- Docs updated: CLAUDE.md, HELP.md, TODO, MASTER, ROADMAP (50 tools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:38:38 -04:00
Scott Idem
b9a78819ac docs: add LLM wiki concept (Karpathy pattern) to ARCH__FUTURE.md
Inara's exploration of a living-wiki knowledge compilation architecture
as an alternative to RAG — three-layer model, ingest/query/lint ops,
and a mapping to existing Cortex concepts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:22:55 -04:00
Scott Idem
3672fa1506 docs: comprehensive doc audit — sync all docs to current state
- MASTER.md: tool count 40→47, add proactive notifications + spawn_agent rows, date bump
- ROADMAP.md: mark local orchestrator/web push/proactive notifs/spawn_agent/web_read/session_read as done, date bump
- ARCH__CHANNELS.md: rewrite notification channel config section — all 4 channels, all triggers, on-demand endpoints
- ARCH__SYSTEM.md: update tools/ module list to include files, agents
- README.md: update LLM backends in architecture diagram, add browser push to channels table
- CLAUDE.md: add doc update checklist to Documentation Philosophy section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:13:45 -04:00
Scott Idem
52c19afbcc fix: raise web_read and http_fetch max_chars cap to 128K
Both tools now accept max_chars up to 131072 to accommodate long
documentation pages and large API responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:08:17 -04:00
Scott Idem
17e8869d12 docs: update tool count (45→47), HELP.md, and TODO for new web/file tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:05:04 -04:00
Scott Idem
7c3291960a feat: web_read (trafilatura), session_read, http_fetch max_chars
web_read(url, max_chars=16000) — fetches a URL and extracts clean article
text via trafilatura, stripping ads/nav/boilerplate. Returns markdown.

session_read(date) — reads a full session log by YYYY-MM-DD date; lists
available dates if the requested one is not found.

http_fetch gains a max_chars param (default 8192, max 32768) so the cap
is configurable instead of hardcoded.

Tool count: 45 → 47.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:04:24 -04:00
Scott Idem
a99ebb8c30 feat: retry button for orchestrator errors + explicit client timeout
Extract orchestrator inner loop into _doOrchestrate() so the retry button
can re-run without re-adding the user message to DOM or history — same
pattern as the existing chat retry.

Also set AsyncOpenAI(timeout=settings.timeout_local) so slow remote models
(OpenRouter/DeepSeek) get the same 300s budget as local chat calls instead
of the SDK default which varies by connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 12:39:34 -04:00
Scott Idem
ff154b1ec0 docs: update CLAUDE.md, HELP.md, and TODO for notifications page + push fix
- CLAUDE.md: date → 2026-05-08, add Proactive notifications row to channel table
- HELP.md: update Notifications settings entry, expand Push Notifications section
  with channel config link, add test API endpoints to reference table
- TODO__Agents.md: mark notifications dedicated page and pywebpush fix as done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:58:47 -04:00
Scott Idem
c21f9a23ec fix: use Vapid.from_pem() instead of passing PEM string to webpush()
pywebpush 2.x routes string keys through Vapid.from_string() which only
handles raw/DER base64 — not PEM. Pre-build the Vapid object so the key
deserializes correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:56:17 -04:00
Scott Idem
19475610be feat: move Notifications to its own settings sub-page
Adds GET /settings/notifications (dedicated page with channel form + two
test buttons) and updates POST /settings/notifications to render that page.
Settings page now shows a compact link card instead of the full form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:43:52 -04:00
Scott Idem
3c7ecf4e4f feat: notification test endpoints — POST /api/push/test and /api/push/reminders/check
- POST /api/push/test: sends "Test notification from Cortex" via the
  user's configured notification channel (web_push / NCT / email / etc.)
- POST /api/push/reminders/check: runs the daily reminder check immediately
  for the current user, returns reminders_found count

Both require an active session cookie. Useful for verifying channel setup
without waiting for the 09:00 scheduler job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:34:58 -04:00
Scott Idem
64020ad982 feat: proactive notifications — web_push channel + daily reminder check
Routes web_push through notification.py alongside NCT/email/Google Chat,
and fires daily reminder summaries via the scheduler.

- notification.py: _notify_web_push() + "web_push" case in notify();
  all four channels (web_push/email/nextcloud/google_chat) now routable
- scheduler.py: _run_reminder_check() daily at 09:00 — reads due reminders
  per persona via set_context(), formats up to 3 entries, calls notify()
- routers/settings.py: "web_push" added to valid notification_channel values
- static/settings.html: "Browser Push Notification" option in channel selector
- TODO__Agents.md: proactive notifications section marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:28:49 -04:00
Scott Idem
47d23a7b2f feat: per-model max_rounds for Gemini orchestrator engine
Mirrors the pattern already in openai_orchestrator.py. The Gemini engine
was still hardcoded to the global orchestrator_max_rounds setting.

- orchestrator_engine.py: max_rounds param on run() and _run_from_contents();
  effective_limit = min(per_model_limit, global_limit); stored in checkpoint
  so resume() respects it across confirmation gates
- routers/orchestrator.py: passes orch_model.get("max_rounds") to run()
- tools/agents.py: passes model_cfg.get("max_rounds") for gemini_api spawns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:54:37 -04:00
Scott Idem
09d775b47b feat: spawn_agent tool + host max_concurrent + docs
Adds a synchronous sub-agent spawning tool that lets the orchestrator
delegate tasks to a specific role's model and tool set.

- cortex/tools/agents.py: spawn_agent(task, role, tier, timeout, max_rounds)
  - Supports local_openai and gemini_api model types
  - Per-host asyncio semaphore (keyed by host_id or model type)
  - asyncio.wait_for() enforces timeout; admin-only tool
- cortex/model_registry.py: max_concurrent field in host schema (default 3,
  clamped 1-20); backfilled on _normalize() for existing hosts
- cortex/routers/local_llm.py + local_llm.html: "Max parallel" number input
  in host add/edit forms
- cortex/tools/__init__.py: spawn_agent registered in TOOL_CATEGORIES["Agents"],
  _CALLABLES, TOOL_ROLES (admin), and _ALL_DECLARATIONS
- Docs: TOOLS.md count 44→45, spawn_agent section; HELP.md tool table updated;
  ARCH__FUTURE.md Round 2 completed items; TODO__Agents.md spawn_agent checked;
  CLAUDE.md tool count and list updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:48:21 -04:00
Scott Idem
6ad7597db8 feat: per-role inject_datetime toggle for system prompt
Each role can now disable the current date/time header injected into the
system prompt. Default is true (all existing roles unchanged). Useful for
pure processing roles (summarizer, classifier, translator) where temporal
context is irrelevant or could cause unexpected model behavior.

Changes:
- model_registry: set_role_config/get_role_config gain inject_datetime field
- context_loader: load_context gains inject_datetime param (default True)
- orchestrator router: passes inject_datetime from role_cfg to load_context
- local_llm router: reads inject_datetime from POST body, passes to registry;
  role_config_data_js includes the field
- local_llm.html: checkbox in role config panel; populate on open, save on submit

Session logs still timestamp every turn (HH:MM header in YYYY-MM-DD.md files)
regardless of this setting — the toggle only affects the system prompt header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:53:35 -04:00
Scott Idem
8e512d4e11 feat: reminders due-date support + context filtering
reminders_add now accepts optional due: YYYY-MM-DD parameter.
Due date stored as first line of section body in REMINDERS.md.

context_loader.py calls load_due_reminders() instead of loading REMINDERS.md
wholesale — future-dated reminders are suppressed in the system prompt until
their date arrives. Undated reminders always surface (backward compatible).

reminders_list shows due status per entry: [OVERDUE by N days], [due TODAY],
or [due: YYYY-MM-DD] for future items. All reminders visible via the tool
regardless of date; only context surfacing is filtered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:46:45 -04:00
Scott Idem
750cde489d feat: session_search tool + tool expansion docs update
session_search (tools/files.py):
- Full-text search across past session logs, exposed to the orchestrator
- Params: query (required), limit (default 5, max 20)
- Returns dated excerpts, newest first; own sessions only via ContextVars
- User-level — no TOOL_ROLES gating needed
- Registered in __init__.py callables + TOOL_CATEGORIES["Files"]

ARCH__FUTURE.md §2: updated tool count to 44, marked prior tools complete,
added Round 2 planned tools table (session_search now done, reminders due dates,
http_post, nc_talk_history, task_list priority filter, http_fetch max_chars),
noted datetime_now is not needed (already in system prompt via context_loader)

TODO__Agents.md: session_search checked off, Round 2 task list added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:41:26 -04:00
68 changed files with 10109 additions and 1848 deletions

6
.gitignore vendored
View File

@@ -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*

View File

@@ -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 14)
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
@@ -185,6 +189,19 @@ Cortex is a no-black-box system. Docs must match reality — at all times.
- **CLAUDE.md + ARCH__*.md are the developer contract:** Update them as the architecture evolves.
- **Stale docs are bugs.** If you notice drift, fix it before moving on.
### Doc update checklist (run after any significant change)
| Doc | Update when |
|---|---|
| `CLAUDE.md` | New tool, channel, router, major design change, tool count |
| `cortex/static/HELP.md` | Any user-visible feature — tools, settings, UI, API 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 |
---
## Adding a New Tool
@@ -192,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
@@ -237,7 +260,7 @@ clearly asked for a directory to be unblocked.
---
## Current State (2026-05-06)
## Current State (2026-05-12)
Cortex is running and stable. All channels are live:
@@ -252,17 +275,30 @@ Cortex is running and stable. All channels are live:
| Tool audit log | ✅ Live | Every tool call logged to `home/{user}/tool_audit/YYYY-MM-DD.jsonl` |
| 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)
**40 orchestrator tools:** web_search, http_fetch,
file_read/list/write, 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.
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.

View File

@@ -182,10 +182,10 @@ Back it up separately — it is required to restore from any snapshot.
└─ POST /channels/google-chat/{username} — Google Chat Add-on (per-user)
LLM Backends
• Claude CLI — primary, all user-facing responses
• Gemini CLI — fallback
• Gemini API — orchestrator tool loop only (not general chat)
• Local — Open WebUI/Ollama on scott_gaming (private/offline)
• Claude CLI — primary, all user-facing responses
• Gemini CLI — fallback
• Gemini API — orchestrator tool loop (two-brain: Gemini plans, Claude responds)
• Local OpenAI — Open WebUI/Ollama on scott_gaming; also runs local orchestrator loop
Persona context loaded from home/{user}/persona/{name}/
```
@@ -213,11 +213,12 @@ Context is loaded at request time from `home/{user}/persona/{name}/` via `cortex
Webhook endpoints are per-user — each user configures their own secrets in `home/{username}/channels.json`.
| Channel | Status | Endpoint |
| Channel | Status | Endpoint / Notes |
|---|---|---|
| Web UI | Live | `https://cortex.dgrzone.com` — session auth (login form + JWT cookie) |
| Nextcloud Talk | Live | `POST /webhook/nextcloud/{username}` — HMAC-signed, async reply |
| Google Chat | Live | `POST /channels/google-chat/{username}` — Workspace Add-on, JWT auth |
| Browser Push | Live | VAPID push notifications — subscribe via ☰ menu; proactive reminders + distill alerts |
See `docs/NEXTCLOUD_TALK_BOT.md` and `docs/GOOGLE_CHAT_BOT.md` for setup instructions.

View File

@@ -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
View 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))

View File

@@ -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")

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from pathlib import Path
from persona import persona_path
from tools.reminders import load_due_reminders
_STATIC_DIR = Path(__file__).parent / "static"
@@ -19,6 +20,9 @@ def load_context(
include_mid: bool = True,
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.
@@ -37,9 +41,18 @@ def load_context(
inara_dir = persona_path()
parts = []
# ── 0. Current date and time (always — injected first so it's prominent) ──
now = datetime.now().astimezone()
parts.append(f"--- System ---\nCurrent date and time: {now.strftime('%A, %Y-%m-%d at %I:%M %p %Z')}")
# ── 0. System block — date/time and session mode (injected first so it's prominent) ──
system_lines = []
if inject_datetime:
now = datetime.now().astimezone()
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:
@@ -80,12 +93,11 @@ def load_context(
parts.append(f"--- HELP.md ---\n{help_path.read_text()}")
# ── 4. Pending reminders (tier 2+) ────────────────────────────
# Written by cron jobs; cleared by Inara after acting on them.
reminders_path = inara_dir / "REMINDERS.md"
if reminders_path.exists() and reminders_path.stat().st_size > 10:
content = reminders_path.read_text().strip()
if content:
parts.append(f"--- REMINDERS.md ---\n{content}")
# Only due and undated reminders are surfaced — future-dated ones
# are stored in REMINDERS.md but suppressed until their date arrives.
content = load_due_reminders()
if content:
parts.append(f"--- REMINDERS.md ---\n{content}")
# ── 5. Tiered memory — long → mid → short ─────────────────────
# Short is last so it sits closest to the conversation turn.

View File

@@ -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 (131) in {original!r}, got {s!r}")
if not 1 <= d <= 31:
raise ValueError(f"Day must be 131 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 (112) in {original!r}, got {s!r}")
if not 1 <= m <= 12:
raise ValueError(f"Month must be 112 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

View File

@@ -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

View File

@@ -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",

View File

@@ -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 ───────────────────────────────────────────────────────────────────
@@ -189,6 +222,7 @@ def _normalize(data: dict) -> dict:
"""Back-fill missing fields introduced by schema additions."""
for h in data.get("hosts", []):
h.setdefault("host_type", "openwebui")
h.setdefault("max_concurrent", 3)
data.setdefault("providers", _default_providers())
data["providers"].setdefault("anthropic", {"credentials": [{"id": "cli", "label": "Claude CLI (OAuth)", "type": "cli"}]})
data["providers"].setdefault("google", {"accounts": []})
@@ -352,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)
@@ -416,17 +460,27 @@ def get_best_local_model(username: str, role: str = "chat") -> dict | None:
return None
def set_role_config(username: str, role: str, system_append: str, tools: list[str] | None) -> None:
"""Save system_append and tools allow-list for a role.
def set_role_config(
username: str,
role: str,
system_append: str,
tools: list[str] | None,
inject_datetime: bool = True,
inject_mode: bool = True,
) -> None:
"""Save system_append, tools allow-list, and per-injection flags for a role.
tools=None clears the allow-list (role uses all accessible tools).
tools=[] would mean no tools at all — validate in the caller if that's undesired.
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", {})
if role not in roles:
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:
@@ -436,17 +490,28 @@ def set_role_config(username: str, role: str, system_append: str, tools: list[st
def get_role_config(username: str, role: str) -> dict:
"""
Return supplemental config for a role: system_append and tools.
Return supplemental config for a role: system_append, tools, and injection flags.
Both keys are optional in the registry — missing means "use defaults":
system_append: str — appended to the system prompt for this role
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,
"system_append": role_cfg.get("system_append", ""),
"tools": effective_tools,
"inject_datetime": role_cfg.get("inject_datetime", True),
"inject_mode": role_cfg.get("inject_mode", True),
}
@@ -539,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 []
@@ -591,21 +658,90 @@ 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,
label: str, api_url: str, api_key: str,
host_type: str = "openwebui") -> str:
host_type: str = "openwebui",
max_concurrent: int = 3) -> str:
"""Create or update a host. Returns the host ID."""
data = _load(username)
host_type = host_type if host_type in ("openwebui", "openai") else "openwebui"
max_concurrent = max(1, min(int(max_concurrent), 20))
if host_id:
for h in data["hosts"]:
if h["id"] == host_id:
h["label"] = label.strip()
h["api_url"] = api_url.strip()
h["host_type"] = host_type
h["label"] = label.strip()
h["api_url"] = api_url.strip()
h["host_type"] = host_type
h["max_concurrent"] = max_concurrent
if api_key.strip():
h["api_key"] = api_key.strip()
_save(username, data)
@@ -614,11 +750,12 @@ def save_host(username: str, host_id: str | None,
host_id = secrets.token_hex(4)
data["hosts"].append({
"id": host_id,
"label": label.strip(),
"api_url": api_url.strip(),
"api_key": api_key.strip(),
"host_type": host_type,
"id": host_id,
"label": label.strip(),
"api_url": api_url.strip(),
"api_key": api_key.strip(),
"host_type": host_type,
"max_concurrent": max_concurrent,
})
_save(username, data)
return host_id
@@ -645,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 []
@@ -653,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
@@ -694,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 = {
@@ -744,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.

View File

@@ -114,6 +114,18 @@ async def _notify_google_chat(webhook_url: str, message: str, username: str) ->
logger.error("notify Google Chat error for %s: %s", username, e)
async def _notify_web_push(username: str, message: str) -> None:
"""Send a browser push notification."""
import push_utils
result = await push_utils.send_push(username, "Cortex", message, "")
if "error" in result:
logger.warning("notify web_push error for %s: %s", username, result["error"])
elif result.get("sent", 0) == 0:
logger.debug("notify web_push: no subscriptions for %s", username)
else:
logger.info("notify web_push → %s (%d device(s))", username, result["sent"])
async def notify(username: str, message: str, channel: str | None = None) -> None:
"""Send a notification to the user's preferred outbound channel.
@@ -123,6 +135,7 @@ async def notify(username: str, message: str, channel: str | None = None) -> Non
3. "nextcloud" if notification_room is configured
4. Silent no-op
Supported channels: "web_push", "email", "nextcloud", "google_chat"
Configure via home/{user}/channels.json — see module docstring.
"""
from auth_utils import get_user_channels
@@ -137,7 +150,10 @@ async def notify(username: str, message: str, channel: str | None = None) -> Non
else:
return
if target == "email":
if target == "web_push":
await _notify_web_push(username, message)
elif target == "email":
email_override = channels.get("notification_email", "").strip() or None
await _notify_email(username, message, email_override=email_override)

View File

@@ -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:
@@ -405,11 +480,14 @@ def _build_client(
base_url = api_url.rstrip("/")
if host_type == "openwebui":
base_url = base_url + "/api"
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=settings.timeout_local)
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:

View File

@@ -91,6 +91,7 @@ class OrchestrateCheckpoint:
confirm_allow: frozenset = field(default_factory=frozenset)
confirm_deny: frozenset = field(default_factory=frozenset)
rounds_used: int = 0
max_rounds: int | None = None
@dataclass
@@ -98,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
@@ -114,6 +116,10 @@ async def run(
tool_list: list[str] | None = None,
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.
@@ -151,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(
@@ -173,6 +182,7 @@ async def run(
confirm_deny=_confirm_deny,
starting_round=0,
gemini_api_key=api_key,
max_rounds=max_rounds,
)
if checkpoint:
@@ -199,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)
@@ -248,6 +263,7 @@ async def resume(checkpoint: OrchestrateCheckpoint, confirmed: bool) -> Orchestr
confirm_deny=checkpoint.confirm_deny,
starting_round=checkpoint.rounds_used,
gemini_api_key=api_key,
max_rounds=checkpoint.max_rounds,
)
if new_checkpoint:
@@ -289,14 +305,17 @@ async def _run_from_contents(
starting_round: int = 0,
gemini_api_key: str | None = None,
tool_list: list[str] | None = None,
max_rounds: int | None = None,
) -> tuple[str, OrchestrateCheckpoint | None]:
"""
Run the ReAct loop from the current contents state.
Returns (gemini_summary, checkpoint) — checkpoint is set if confirmation is needed.
"""
gemini_summary = ""
per_model_limit = max_rounds or settings.orchestrator_max_rounds
effective_limit = min(per_model_limit, settings.orchestrator_max_rounds)
for round_num in range(starting_round, settings.orchestrator_max_rounds):
for round_num in range(starting_round, effective_limit):
logger.info("Orchestrator round %d for task: %.80s", round_num + 1, task)
response = await asyncio.to_thread(
@@ -400,15 +419,16 @@ async def _run_from_contents(
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
rounds_used=round_num + 2,
max_rounds=max_rounds,
)
return gemini_summary, checkpoint
contents.append(types.Content(role="user", parts=response_parts))
else:
logger.warning("Orchestrator hit max rounds (%d)", settings.orchestrator_max_rounds)
logger.warning("Orchestrator hit max rounds (%d)", effective_limit)
gemini_summary = (
f"Reached the tool iteration limit ({settings.orchestrator_max_rounds} rounds). "
f"Reached the tool iteration limit ({effective_limit} rounds). "
"Here is what was gathered so far:\n\n"
+ "\n\n".join(f"**{t['tool']}**: {t['result'][:500]}" for t in tool_call_log)
)

View File

@@ -67,12 +67,14 @@ def _get_private_key_pem() -> str:
def _send_one(sub: dict, payload: dict) -> bool:
"""Send a push to a single subscription. Returns False if the endpoint is stale (410)."""
from pywebpush import webpush, WebPushException
from py_vapid import Vapid
try:
vapid = Vapid.from_pem(_get_private_key_pem().encode())
webpush(
subscription_info=sub,
data=json.dumps(payload),
vapid_private_key=_get_private_key_pem(),
vapid_private_key=vapid,
vapid_claims={"sub": settings.vapid_contact},
)
return True

View File

@@ -19,11 +19,17 @@ python-multipart>=0.0.9 # required by FastAPI for Form() data
# Async HTTP client — used for local OpenAI-compatible backend (Open WebUI / Ollama)
httpx>=0.27.0
# Web content extraction — strips ads/nav/boilerplate, returns clean article text
trafilatura>=1.6.0
# OpenAI-compatible client — tool calling for OpenRouter / LiteLLM / any OAI-compat host
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

View File

@@ -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
View 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}"))

View File

@@ -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)

View 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)

View File

@@ -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-keysave/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,72 +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>
<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:
@@ -196,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"]}">
@@ -251,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"
@@ -288,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}" '
@@ -305,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">'
@@ -314,6 +422,19 @@ def _render(username: str, success: str = "", error: str = "") -> str:
f'placeholder="Extra instructions injected into the system prompt when this role is active…"></textarea>'
f'</div>'
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'<span>Inject current date &amp; time into system prompt</span>'
f'</label>'
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 '
f'<span class="rcp-hint">— all checked means no restriction (use all accessible tools)</span></label>'
f'<div class="rcp-tools" id="rcp-tools-{role}"></div>'
@@ -322,47 +443,61 @@ 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({
role: {
"system_append": roles.get(role, {}).get("system_append", ""),
"tools": roles.get(role, {}).get("tools") or None,
"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:
@@ -377,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)
@@ -396,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)
@@ -407,25 +542,51 @@ 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)
async def save_host(
request: Request,
host_id: str = Form(""),
label: str = Form(""),
api_url: str = Form(""),
api_key: str = Form(""),
host_type: str = Form("openwebui"),
request: Request,
host_id: str = Form(""),
label: str = Form(""),
api_url: str = Form(""),
api_key: str = Form(""),
host_type: str = Form("openwebui"),
max_concurrent: int = Form(3),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not api_url.strip():
return HTMLResponse(_render(username, error="API URL is required."))
reg.save_host(username, host_id or None, label, api_url, api_key, host_type)
return HTMLResponse(_render(username, success="Host saved."))
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, request, success="Host saved."))
@router.post("/settings/local/host/{host_id}/remove", include_in_schema=False)
@@ -434,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:
@@ -461,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,
@@ -485,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)
@@ -540,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")
@@ -574,10 +775,11 @@ async def set_role(request: Request) -> JSONResponse:
@router.post("/api/models/role-config")
async def set_role_config(request: Request) -> JSONResponse:
"""AJAX: save system_append and tool allow-list for a role.
"""AJAX: save system_append, tool allow-list, and inject_datetime flag for a role.
Body: {"role": "coder", "system_append": "...", "tools": ["web_search", ...] | null}
Body: {"role": "coder", "system_append": "...", "tools": [...] | null, "inject_datetime": true}
tools=null clears the allow-list (role uses all accessible tools).
inject_datetime=false suppresses the date/time header for pure processing roles.
"""
username = _get_user(request)
if not username:
@@ -587,18 +789,23 @@ async def set_role_config(request: Request) -> JSONResponse:
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
role = body.get("role", "").strip()
system_append = body.get("system_append", "")
tools = body.get("tools") # list[str] or None
role = body.get("role", "").strip()
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)
logger.info("role config saved: %s %s (tools=%s)", username, role,
len(tools) if tools is not None else "all")
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})

View File

@@ -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)

View File

@@ -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:
@@ -203,6 +208,9 @@ async def _run_job(job_id: str, req: OrchestrateRequest, user: str) -> None:
include_mid=req.include_mid,
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()
@@ -216,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(
@@ -228,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 = (
@@ -246,6 +258,10 @@ 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_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:
@@ -268,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)
@@ -314,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)
@@ -338,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
@@ -346,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:
@@ -360,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))

View File

@@ -58,3 +58,63 @@ async def unsubscribe(req: UnsubscribeRequest, request: Request) -> dict:
username = _require_user(request)
found = push_utils.remove_subscription(username, req.endpoint)
return {"ok": True, "found": found}
@router.post("/test")
async def notify_test(request: Request) -> dict:
"""Send a test notification via the user's configured notification channel.
Useful for verifying channel setup (web push, NCT, email, etc.) without
waiting for a cron job or reminder to fire naturally.
"""
username = _require_user(request)
from notification import notify
await notify(username, "Test notification from Cortex — your notification channel is working.")
return {"ok": True, "user": username}
@router.post("/reminders/check")
async def reminder_check_now(request: Request) -> dict:
"""Run the reminder check for the current user immediately.
Same logic as the daily 09:00 scheduler job, but scoped to one user
and fired on demand. Returns how many reminders were found and whether
a notification was sent.
"""
import re
username = _require_user(request)
from persona import list_user_personas, set_context
from notification import notify
total_sent = 0
for persona_name in list_user_personas(username):
set_context(username, persona_name)
from tools.reminders import load_due_reminders
content = load_due_reminders()
if not content:
continue
entries = []
for line in content.splitlines():
m = re.match(r"^\d+\.\s+(.+)", line.strip())
if m:
text = re.sub(r"\[(OVERDUE|due TODAY|due: \S+)\]", "", m.group(1)).strip()
if text:
entries.append(text)
if not entries:
continue
count = len(entries)
if count == 1:
msg = f"Reminder: {entries[0]}"
else:
bullet_list = "\n".join(f"{e}" for e in entries[:3])
tail = f"\n…and {count - 3} more" if count > 3 else ""
msg = f"{count} reminders due:\n{bullet_list}{tail}"
await notify(username, msg)
total_sent += count
return {"ok": True, "user": username, "reminders_found": total_sent}

View File

@@ -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,6 +53,54 @@ 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)
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:
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
return html
def _settings_page(username: str, personas: list[str], back_persona: str = "", success: str = "", error: str = "") -> str:
html = (_STATIC / "settings.html").read_text()
html = html.replace("{{ username }}", username)
@@ -74,25 +121,13 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
allowlist_text = ""
html = html.replace("{{ email_allowlist }}", allowlist_text)
# Notification channel settings
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 "")
html = html.replace("{{ notify_channel }}", notify_ch)
html = html.replace("{{ notify_email_override }}", notify_email)
html = html.replace("{{ nc_notify_room }}", nc_room)
html = html.replace("{{ gc_webhook }}", gc_webhook)
# 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>
@@ -103,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
)
@@ -113,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:
@@ -261,19 +315,35 @@ async def rename_persona(
return RedirectResponse("/settings", status_code=302)
@router.get("/settings/notifications", include_in_schema=False)
async def notifications_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(_notifications_page(username, back_persona))
@router.post("/settings/notifications", include_in_schema=False)
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:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
back_persona = _preferred_persona(request, username)
channels_path = app_settings.home_root() / username / "channels.json"
@@ -284,7 +354,7 @@ async def save_notifications(
# Top-level notification preference
notification_channel = notification_channel.strip()
if notification_channel in ("email", "nextcloud", "google_chat"):
if notification_channel in ("web_push", "email", "nextcloud", "google_chat"):
channels["notification_channel"] = notification_channel
else:
channels.pop("notification_channel", None)
@@ -296,41 +366,41 @@ 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(_settings_page(username, personas, 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."))
return HTMLResponse(_notifications_page(username, back_persona, success="Notification settings saved."))
@router.post("/settings/email-allowlist", include_in_schema=False)
@@ -349,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."))

View 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.",
))

View File

@@ -69,6 +69,47 @@ async def _run_long() -> None:
logger.error("auto distill long [%s/%s] failed: %s", u, p, e)
async def _run_reminder_check() -> None:
"""Notify users of any due or overdue reminders (fires once daily at 09:00)."""
import re
from notification import notify
from persona import set_context
for u, p in _all_personas():
try:
set_context(u, p)
from tools.reminders import load_due_reminders
content = load_due_reminders()
if not content:
continue
# Extract numbered entries (lines like "1. [label] text" or "1. text")
entries = []
for line in content.splitlines():
m = re.match(r"^\d+\.\s+(.+)", line.strip())
if m:
# Strip status tags ([OVERDUE], [due TODAY], etc.) for display
text = re.sub(r"\[(OVERDUE|due TODAY|due: \S+)\]", "", m.group(1)).strip()
if text:
entries.append(text)
if not entries:
continue
count = len(entries)
if count == 1:
msg = f"Reminder: {entries[0]}"
else:
bullet_list = "\n".join(f"{e}" for e in entries[:3])
tail = f"\n…and {count - 3} more" if count > 3 else ""
msg = f"{count} reminders due:\n{bullet_list}{tail}"
await notify(u, msg)
logger.info("reminder check [%s/%s]: notified %d reminder(s)", u, p, count)
except Exception as e:
logger.error("reminder check [%s/%s] failed: %s", u, p, e)
def get_scheduler() -> AsyncIOScheduler | None:
"""Return the running scheduler instance (used by cron tools for live add/remove)."""
return _scheduler
@@ -93,6 +134,10 @@ def start() -> None:
_scheduler.add_job(_run_long, "cron", day=1, hour=4, minute=0, id="distill_long")
logger.info("scheduled: distill_long monthly on 1st at 04:00")
# Daily reminder notification check — 09:00
_scheduler.add_job(_run_reminder_check, "cron", hour=9, minute=0, id="reminder_check")
logger.info("scheduled: reminder_check daily at 09:00")
# Load user-defined cron jobs from CRONS.json
_load_user_crons()

View File

@@ -6,7 +6,7 @@
and are appended automatically by help.html when present.
-->
*Last updated: 2026-05-08*
*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,23 +82,33 @@ Orchestrated sessions persist to history exactly like regular chat.
### Available Tools
40 tools across 11 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` |
| **Files** | `file_read`, `file_list`, `file_write` |
| **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`, `aider_run` |
| **Home Assistant** | `ha_get_state`, `ha_get_states`, `ha_call_service` |
File, Shell, System, 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
@@ -144,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.
@@ -175,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** | Set which channel (NC Talk, Google Chat, email) Inara uses for proactive messages |
| **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.) |
@@ -226,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**
@@ -255,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 cataloguses 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.
@@ -263,25 +269,34 @@ 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).
**Inject timestamp:** Each role card has an "Inject current date & time into system prompt" checkbox (default on). Disable it for pure processing roles (summarizer, classifier, translator) that don't need clock awareness.
---
## 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.
@@ -292,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.
---
@@ -332,7 +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 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.
---
@@ -378,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 |
@@ -421,6 +485,8 @@ For direct access or scripting:
| `GET` | `/api/push/vapid-key` | VAPID public key (for push subscription) |
| `POST` | `/api/push/subscribe` | Register a push subscription |
| `DELETE` | `/api/push/subscribe` | Remove a push subscription |
| `POST` | `/api/push/test` | Send a test notification via configured channel |
| `POST` | `/api/push/reminders/check` | Run reminder check immediately; returns `{"reminders_found": n}` |
| `GET` | `/api/audit/files` | List available audit log dates (own data) |
| `GET` | `/api/audit/day?date=` | Tool call entries for a specific date (own data) |
| `GET` | `/api/audit/recent` | Recent tool calls across days (admin) |
@@ -438,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
}
```

View File

@@ -1,6 +1,6 @@
# Tool Reference
> This reference covers all 44 orchestrator tools available when the ⚡ toggle is on.
> This reference covers all 45 orchestrator tools available when the ⚡ toggle is on.
> Tools are invoked automatically by the orchestrator — you don't call them directly.
¹ **Admin only** — requires the `admin` role. Invisible to regular users.
@@ -113,3 +113,11 @@ Private, durable notes visible only to the orchestrator — not surfaced to user
| `agent_notes_write` | Overwrite the notes file completely |
| `agent_notes_append` | Append a timestamped entry (keeps last 3 backups automatically) |
| `agent_notes_clear` | Erase all notes (backs up first) |
## Agents ¹
Spawn sub-agents that run their own tool loop using a specific role's model and tools.
| Tool | What it does |
|---|---|
| `spawn_agent` ¹ | Spawn a sub-agent synchronously — blocks until the task completes or times out. Params: `task`, `role` (default `chat`), `tier` (14, default 1), `timeout` seconds, `max_rounds` override. Only works with `local_openai` and `gemini_api` models. |

View File

@@ -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,58 +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();
}
async function sendOrchestrate() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
activeController = new AbortController();
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '⚡ working…');
// 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',
@@ -1244,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,
}),
@@ -1327,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);
@@ -1336,14 +1563,71 @@
thinkingDiv.textContent = 'Stopped.';
} else {
thinkingDiv.className = 'message error';
thinkingDiv.textContent = `Error: ${err.message}`;
thinkingDiv.innerHTML = '';
const errSpan = document.createElement('span');
errSpan.textContent = `Error: ${err.message}`;
thinkingDiv.appendChild(errSpan);
const retryBtn = document.createElement('button');
retryBtn.className = 'retry-btn';
retryBtn.textContent = '↺ Retry';
retryBtn.addEventListener('click', async () => {
if (currentHistory.at(-1)?.role === 'user') currentHistory.pop();
currentHistory.push({ role: 'user', content: text });
thinkingDiv.className = 'message assistant thinking';
thinkingDiv.textContent = '⚡ working…';
activeController = new AbortController();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
setProcessing(true);
startRunTimer();
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
activeController = null;
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
});
thinkingDiv.appendChild(retryBtn);
}
}
}
async function sendOrchestrate() {
const rawText = inputEl.value.trim();
if ((!rawText && !_pendingAttach) || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
setProcessing(true);
startRunTimer();
activeController = new AbortController();
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(serverText, thinkingDiv, userMsgDiv);
activeController = null;
headerEmoji.classList.remove('processing');
setProcessing(false);
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
stopRunTimer();
inputEl.focus();
}
@@ -2079,4 +2363,4 @@
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
}

172
cortex/static/crons.html Normal file
View 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>

View File

@@ -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 &amp; 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 &amp; 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>`;
}
}

View File

@@ -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">

View 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

View File

@@ -0,0 +1,348 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Notifications</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>
/* ── 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); }
/* ── Test result feedback (JS-toggled display) ── */
#test-result { display: none; }
</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 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 -->
<form method="POST" action="/settings/notifications">
<!-- Channel selector -->
<div class="section">
<h2>Channel</h2>
<div class="field">
<label for="notification_channel">Default outbound channel</label>
<select id="notification_channel" name="notification_channel"
data-value="{{ notify_channel }}">
<option value="">None (disabled)</option>
<option value="web_push">Browser Push Notification</option>
<option value="email">Email</option>
<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 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 your login email"
autocomplete="off">
</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 }}/&lt;webhook_id&gt;</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" style="margin-top:2rem;">
<h2>Test</h2>
<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="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 id="test-result"
class="mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed"></div>
</div>
</div>
<script>
// Set channel select to saved value
const sel = document.getElementById('notification_channel');
if (sel) {
const saved = sel.dataset.value;
if (saved) {
for (const opt of sel.options) {
if (opt.value === saved) { opt.selected = true; break; }
}
}
}
// Test buttons
const resultEl = document.getElementById('test-result');
function showResult(ok, msg) {
resultEl.textContent = msg;
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';
}
async function apiPost(url, btnEl, label) {
btnEl.disabled = true;
btnEl.textContent = label + '…';
resultEl.style.display = 'none';
try {
const r = await fetch(url, { method: 'POST' });
const data = await r.json();
if (r.ok && data.ok) {
if (url.includes('reminders')) {
const n = data.reminders_found ?? 0;
showResult(true, n > 0
? `Found ${n} due reminder${n !== 1 ? 's' : ''} — notification sent.`
: 'No due reminders found — nothing sent.');
} else {
showResult(true, 'Notification sent. Check your configured channel.');
}
} else {
showResult(false, data.detail || 'Request failed.');
}
} catch (e) {
showResult(false, 'Network error: ' + e.message);
} finally {
btnEl.disabled = false;
btnEl.textContent = label;
}
}
document.getElementById('btn-test-notify').addEventListener('click', function() {
apiPost('/api/push/test', this, 'Send Test Notification');
});
document.getElementById('btn-check-reminders').addEventListener('click', function() {
apiPost('/api/push/reminders/check', this, 'Check Reminders Now');
});
</script>
</body>
</html>

189
cortex/static/pg.css Normal file
View 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);
}

View File

@@ -7,275 +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>
<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>
@@ -287,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>
@@ -320,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">
@@ -340,153 +175,52 @@
placeholder=".*@example\.com&#10;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;">
Choose how Inara reaches out proactively — cron jobs, briefs, and future alerts.
Email defaults to your login address when no override is set.
<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>
<form method="POST" action="/settings/notifications">
<form method="POST" action="/settings/http-allowlist">
<div class="field">
<label for="notification_channel">Notification channel</label>
<select id="notification_channel" name="notification_channel"
data-value="{{ notify_channel }}"
style="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;">
<option value="">None (disabled)</option>
<option value="email">Email</option>
<option value="nextcloud">Nextcloud Talk</option>
<option value="google_chat">Google Chat</option>
</select>
<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/&#10;https://n8n.dgrzone.com/webhook/"
spellcheck="false">{{ http_allowlist }}</textarea>
</div>
<div class="field">
<label for="notification_email">Email override
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ notify_email_override }}"
placeholder="Leave blank to use 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>
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
</form>
</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&#10;cron_remove"
autocomplete="off" spellcheck="false">{{ tool_allow }}</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&#10;file_write"
autocomplete="off" spellcheck="false">{{ tool_deny }}</textarea>
</div>
<button type="submit">Save tool permissions</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">
@@ -505,43 +239,37 @@
<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>
<script>
// Restore notification channel dropdown from injected value
(function() {
const sel = document.getElementById('notification_channel');
if (sel) sel.value = sel.dataset.value || '';
})();
// Password confirmation check
document.getElementById('password-form').addEventListener('submit', e => {
const np = document.getElementById('new_password').value;
@@ -563,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();
@@ -583,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 (_) {}
})();
@@ -598,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);
@@ -607,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>';
}
})();
@@ -648,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;

View File

@@ -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 {

View 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&#10;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&#10;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>

View 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

View File

@@ -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}"

View File

@@ -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

View File

@@ -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"},

View File

@@ -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()

View File

@@ -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()

View File

@@ -17,7 +17,7 @@ from google.genai import types
# ── Callable imports ──────────────────────────────────────────────────────────
from tools.web import search as _web_search, http_fetch as _http_fetch
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
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,13 +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.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 ───────────────────────────────────────────────────────
@@ -84,19 +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.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"],
"Files": ["file_read", "file_list", "file_write"],
"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",
@@ -106,6 +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", "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 ─────────────────────────────────────────────────────────
@@ -113,6 +157,8 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
_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,
@@ -123,9 +169,17 @@ _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,
"session_read": _session_read,
"session_search": _session_search,
"shell_exec": _shell_exec,
"claude_allow_dir": _claude_allow_dir,
"cortex_restart": _cortex_restart,
@@ -151,10 +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 ─────────────────────────────────────────────────
@@ -171,8 +240,19 @@ TOOL_ROLES: dict[str, str] = {
"file_list": "admin",
"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.
@@ -183,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}
@@ -198,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
@@ -207,6 +408,10 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
+ _mod_ae_knowledge.DECLARATIONS
+ _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)
@@ -300,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]
@@ -322,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
View 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"],
),
),
]

446
cortex/tools/agents.py Normal file
View File

@@ -0,0 +1,446 @@
"""
Agent spawning and lifecycle tools.
spawn_agent — synchronous or background sub-agent via any configured role model.
agent_status / agent_list / agent_cancel — lifecycle management for background agents.
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>"
# Created lazily on first use; never deleted (module-level singletons)
_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."""
async with _sem_lock:
if key not in _semaphores:
_semaphores[key] = asyncio.Semaphore(max_concurrent)
return _semaphores[key]
async def spawn_agent(
task: str,
role: str = "chat",
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.
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
from auth_utils import get_user_role, get_tool_policy
from persona import get_user
user = get_user() or "scott"
role_cfg = model_registry.get_role_config(user, role)
model_cfg = model_registry.get_model_for_role(user, role)
if not model_cfg:
return f"spawn_agent: no model configured for role '{role}'"
model_type = model_cfg.get("type", "unknown")
if model_type not in ("local_openai", "gemini_api"):
return (
f"spawn_agent: model type '{model_type}' does not support tool-enabled sub-agents. "
f"Assign a local_openai or gemini_api model to role '{role}'."
)
# Determine concurrency key and semaphore limit
host_id = model_cfg.get("host_id")
if host_id:
registry = model_registry.get_registry(user)
host = next((h for h in registry.get("hosts", []) if h["id"] == host_id), None)
max_concurrent = (host or {}).get("max_concurrent", 3)
sem_key = f"host:{host_id}"
else:
max_concurrent = 5 if model_type == "gemini_api" else 3
sem_key = f"type:{model_type}"
sem = await _get_semaphore(sem_key, max_concurrent)
system_prompt = load_context(
tier=tier,
include_long=(tier >= 2),
include_mid=(tier >= 2),
include_short=(tier >= 2),
role_append=role_cfg.get("system_append", ""),
inject_datetime=role_cfg.get("inject_datetime", True),
)
user_role = get_user_role(user)
tool_list = role_cfg.get("tools")
policy = get_tool_policy(user)
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
async def _run() -> str:
if model_type == "local_openai":
import openai_orchestrator
result = await openai_orchestrator.run(
task=task,
system_prompt=system_prompt,
model_cfg=model_cfg,
respond_with_final=True,
user_role=user_role,
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
)
if result.checkpoint:
return (
"Sub-agent requires user confirmation — "
"confirmation gates are not supported inside spawn_agent. "
"Pre-allow the tool in the user's tool policy or use a different role."
)
return result.response or "(sub-agent returned no output)"
# gemini_api
import orchestrator_engine
from auth_utils import get_user_gemini_key
gemini_key = model_cfg.get("api_key") or get_user_gemini_key(user)
result = await orchestrator_engine.run(
task=task,
system_prompt=system_prompt,
session_messages=None,
respond_with_claude=True,
gemini_api_key=gemini_key,
model_name=model_cfg.get("model_name"),
response_role=role,
user_role=user_role,
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
max_rounds=model_cfg.get("max_rounds"),
)
if result.checkpoint:
return (
"Sub-agent requires user confirmation — "
"confirmation gates are not supported inside 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(
"spawn_agent: role=%s tier=%d timeout=%ds task=%.80s",
role, tier, timeout, task,
)
response = await asyncio.wait_for(_run(), timeout=float(timeout))
logger.info("spawn_agent: done role=%s response=%d chars", role, len(response))
return response
except asyncio.TimeoutError:
logger.warning("spawn_agent: timed out after %ds role=%s", timeout, role)
return f"Sub-agent timed out after {timeout}s (role={role})"
except Exception as e:
logger.exception("spawn_agent: failed role=%s", role)
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. "
"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.)."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"task": types.Schema(
type=types.Type.STRING,
description="The complete task description for the sub-agent.",
),
"role": types.Schema(
type=types.Type.STRING,
description=(
"Role determining the model and tools. "
"E.g. 'research' for web lookups, 'coder' for code tasks, "
"'distill' for summarization. Defaults to 'chat'."
),
),
"tier": types.Schema(
type=types.Type.INTEGER,
description=(
"Context tier: 1 = minimal (fast, identity only), "
"2 = standard (+ memory), 3 = + last 2 session logs. "
"Use 1 for pure processing tasks."
),
),
"timeout": types.Schema(
type=types.Type.INTEGER,
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
View 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"],
),
)
]

View File

@@ -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"],
),

View File

@@ -1,21 +1,44 @@
"""
File read tool — restricted to known-safe directory roots.
File read/write/search toolstwo access scopes.
Lets the orchestrator read local files (documentation, notes, config references)
without exposing arbitrary filesystem access. All paths are resolved and checked
against an allowlist of roots before any read 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",
@@ -32,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)
@@ -123,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:
@@ -136,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)
@@ -225,22 +475,241 @@ 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)."""
return await asyncio.to_thread(_sync_session_read, date.strip())
def _sync_session_read(date: str) -> str:
from persona import persona_path
sessions_dir = persona_path() / "sessions"
if not sessions_dir.exists():
return "No session logs found."
target = sessions_dir / f"{date}.md"
if target.exists():
content = target.read_text()
return f"Session log for {date} ({len(content)} chars):\n\n{content}"
available = sorted([f.stem for f in sessions_dir.glob("*.md")], reverse=True)
if not available:
return "No session logs found."
recent = "\n".join(f" {d}" for d in available[:15])
return f"No session log found for '{date}'. Available dates (most recent first):\n{recent}"
async def session_search(query: str, limit: int = 5) -> str:
"""Search past session logs for a keyword or phrase."""
return await asyncio.to_thread(_sync_session_search, query, limit)
def _sync_session_search(query: str, limit: int) -> str:
from persona import persona_path
sessions_dir = persona_path() / "sessions"
if not sessions_dir.exists():
return "No session logs found."
limit = max(1, min(limit, 20))
pattern = re.compile(re.escape(query), re.IGNORECASE)
session_files = sorted(sessions_dir.glob("*.md"), reverse=True)
matches = []
for sf in session_files:
if len(matches) >= limit:
break
try:
text = sf.read_text()
except OSError:
continue
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)
excerpt = text[start:end].strip()
if start > 0:
excerpt = "" + excerpt
if end < len(text):
excerpt = excerpt + ""
matches.append(f"[{sf.stem}] {excerpt}")
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"],
),
@@ -248,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"],
),
@@ -271,11 +744,61 @@ 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"],
),
),
types.FunctionDeclaration(
name="session_read",
description=(
"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')",
),
},
required=["date"],
),
),
types.FunctionDeclaration(
name="session_search",
description=(
"Search past conversation session logs for a keyword or phrase. "
"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)",
),
},
required=["query"],
),
),
]

158
cortex/tools/git.py Normal file
View 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",
),
},
),
),
]

View 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"],
),
),
]

View File

@@ -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=[],
),
),
]

View File

@@ -2,18 +2,20 @@
Reminders tools.
Reminders are stored in persona/REMINDERS.md and automatically surfaced
in the system prompt at Tier 2+. Use these tools to add, list, and clear
pending reminders.
in the system prompt at Tier 2+. Each reminder can have an optional due date —
only due or undated reminders surface in context; future-dated ones are stored
but invisible until their date arrives.
Operations:
reminders_add — append a new reminder entry
reminders_list — return all current reminders (or a message if empty)
reminders_clear — erase all reminders (moved here from cron.py for consistency;
cron.py still calls the same underlying file)
reminders_add — append a new reminder, optional due date (YYYY-MM-DD)
reminders_list — return all reminders with due status (including future)
reminders_remove — remove a single reminder by number
reminders_clear — erase all reminders
"""
import asyncio
from datetime import datetime, timezone
import re
from datetime import datetime, timezone, date as _date
from pathlib import Path
from google.genai import types
@@ -50,6 +52,46 @@ def _sections_to_text(sections: list[tuple[str, str]]) -> str:
return "".join(f"\n## {h}\n\n{b}\n" for h, b in sections)
def _parse_due(body: str) -> _date | None:
"""Extract due date from a 'due: YYYY-MM-DD' line in the body, if present."""
m = re.search(r'^due:\s*(\d{4}-\d{2}-\d{2})', body, re.MULTILINE | re.IGNORECASE)
if not m:
return None
try:
return _date.fromisoformat(m.group(1))
except ValueError:
return None
def _today() -> _date:
return datetime.now().astimezone().date()
def _is_due_or_undated(body: str) -> bool:
"""Return True if this reminder has no due date or its due date is today or past."""
due = _parse_due(body)
return due is None or due <= _today()
def _due_label(body: str) -> str:
"""Return a human-readable due status string for reminders_list output."""
due = _parse_due(body)
if due is None:
return ""
today = _today()
if due < today:
days = (today - due).days
return f" [OVERDUE by {days} day{'s' if days != 1 else ''} — due {due}]"
if due == today:
return " [due TODAY]"
return f" [due: {due}]"
def _body_without_due(body: str) -> str:
"""Strip the due: line from body for display (due status shown in heading line)."""
return re.sub(r'^due:\s*\S+\s*\n?', '', body, count=1, flags=re.MULTILINE | re.IGNORECASE).strip()
# ---------------------------------------------------------------------------
# Sync implementations
# ---------------------------------------------------------------------------
@@ -63,22 +105,29 @@ def _reminders_list() -> str:
return "No pending reminders."
lines = []
for i, (heading, body) in enumerate(sections, 1):
lines.append(f"{i}. {heading}")
if body:
# Indent body so it reads as belonging to the numbered item
for bline in body.splitlines()[:4]: # cap at 4 lines for brevity
status = _due_label(body)
lines.append(f"{i}. {heading}{status}")
display_body = _body_without_due(body)
if display_body:
for bline in display_body.splitlines()[:4]:
lines.append(f" {bline}")
lines.append("")
return "\n".join(lines).rstrip()
def _reminders_add(text: str, label: str | None = None) -> str:
def _reminders_add(text: str, label: str | None = None, due: str | None = None) -> str:
p = _reminders_path()
existing = p.read_text() if p.exists() else ""
heading = label or _now_label()
section = f"\n## {heading}\n\n{text.strip()}\n"
body = text.strip()
if due:
body = f"due: {due}\n{body}"
section = f"\n## {heading}\n\n{body}\n"
p.write_text(existing.rstrip() + "\n" + section)
return f"Reminder added: {heading}"
msg = f"Reminder added: {heading}"
if due:
msg += f" (due: {due})"
return msg
def _reminders_remove(index: int) -> str:
@@ -107,6 +156,31 @@ def _reminders_clear() -> str:
return "All reminders cleared."
# ---------------------------------------------------------------------------
# Public helper for context_loader
# ---------------------------------------------------------------------------
def load_due_reminders() -> str:
"""Return REMINDERS.md content filtered to only due and undated sections.
Called by context_loader at Tier 2+. Future-dated reminders are excluded
from the system prompt until their due date arrives.
"""
p = _reminders_path()
if not p.exists():
return ""
text = p.read_text()
if not text.strip():
return ""
sections = _parse_sections(text)
due_sections = [(h, b) for h, b in sections if _is_due_or_undated(b)]
if not due_sections:
return ""
# Strip the raw due: line from body — the date is already part of the heading context
cleaned = [(h, _body_without_due(b)) for h, b in due_sections]
return _sections_to_text(cleaned).strip()
# ---------------------------------------------------------------------------
# Async wrappers
# ---------------------------------------------------------------------------
@@ -115,8 +189,8 @@ async def reminders_list() -> str:
return await asyncio.to_thread(_reminders_list)
async def reminders_add(text: str, label: str | None = None) -> str:
return await asyncio.to_thread(_reminders_add, text, label)
async def reminders_add(text: str, label: str | None = None, due: str | None = None) -> str:
return await asyncio.to_thread(_reminders_add, text, label, due)
async def reminders_remove(index: int) -> str:
@@ -132,15 +206,17 @@ DECLARATIONS = [
name="reminders_add",
description=(
"Add a new reminder to REMINDERS.md. Reminders are automatically surfaced "
"in your context at the start of each session (Tier 2+). "
"Use this when the user asks you to remember something, follow up on something, "
"or surface a note at the next session."
"in context at the start of each session (Tier 2+). "
"Use this when the user asks you to remember something or follow up on something. "
"Set a due date to suppress the reminder until that date — useful for future tasks "
"that would be noise today."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"text": types.Schema(type=types.Type.STRING, description="The reminder text to add"),
"label": types.Schema(type=types.Type.STRING, description="Optional heading for this reminder (e.g. 'Follow up on NC Talk'). Defaults to current timestamp."),
"text": types.Schema(type=types.Type.STRING, description="The reminder text"),
"label": types.Schema(type=types.Type.STRING, description="Optional heading (e.g. 'Follow up on NC Talk'). Defaults to current timestamp."),
"due": types.Schema(type=types.Type.STRING, description="Optional due date in YYYY-MM-DD format. Reminder is hidden from context until this date arrives. Omit for an always-visible reminder."),
},
required=["text"],
),
@@ -148,9 +224,9 @@ DECLARATIONS = [
types.FunctionDeclaration(
name="reminders_list",
description=(
"Read all current pending reminders from REMINDERS.md. "
"Use this to check what reminders are queued before adding duplicates, "
"or to show the user what's pending."
"Read all pending reminders, including future-dated ones not yet in context. "
"Shows due status for each (due today, overdue, or future date). "
"Use this before adding to avoid duplicates, or to show the user what's queued."
),
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
),
@@ -158,12 +234,12 @@ DECLARATIONS = [
name="reminders_remove",
description=(
"Remove a single reminder by its number. "
"Call reminders_list first to get the numbered list, then pass the number of the reminder to remove."
"Call reminders_list first to get the numbered list, then pass the number to remove."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"index": types.Schema(type=types.Type.INTEGER, description="The number of the reminder to remove (1 = first item in reminders_list output)."),
"index": types.Schema(type=types.Type.INTEGER, description="The number of the reminder to remove (1 = first in reminders_list output)."),
},
required=["index"],
),

View File

@@ -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."),
},
),
),

View File

@@ -1,14 +1,17 @@
"""
Web tools — search (DuckDuckGo) and direct HTTP fetch.
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__)
@@ -56,20 +59,25 @@ async def http_fetch(
method: str = "GET",
body: str | None = None,
timeout: int = 15,
max_chars: int = 8192,
) -> str:
"""Fetch a URL directly and return the response body.
"""Fetch a URL directly and return the raw response body.
Unlike web_search, this hits a specific URL — useful for health checks,
API probing, JSON endpoints, webhook testing, etc.
Response body is capped at 8 KB.
API probing, JSON endpoints, webhook testing, or reading raw page source.
For readable article content, use web_read instead.
Response body is capped at max_chars (default 8192, max 32768).
"""
method = method.upper()
timeout = min(max(int(timeout), 1), 60)
max_chars = min(max(int(max_chars), 100), 131072)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.request(method, url, content=body)
body_text = resp.text[:8192]
return f"HTTP {resp.status_code} {resp.url}\n\n{body_text}"
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:
@@ -77,6 +85,113 @@ async def http_fetch(
return f"Error: {e}"
async def web_read(url: str, max_chars: int = 16000) -> str:
"""Fetch a URL and extract clean readable text, stripping ads, navigation, and boilerplate.
Uses trafilatura to extract the main article content — ideal for blog posts,
documentation, news articles, and any page where you want the text without
surrounding noise. Returns markdown-formatted output.
For raw responses (JSON APIs, health checks), use http_fetch instead.
"""
max_chars = min(max(int(max_chars), 1000), 131072)
return await asyncio.to_thread(_sync_web_read, url, max_chars)
def _sync_web_read(url: str, max_chars: int) -> str:
try:
import trafilatura
except ImportError:
return "web_read requires trafilatura — run: pip install trafilatura"
downloaded = trafilatura.fetch_url(url)
if downloaded is None:
return f"Failed to download content from: {url}"
text = trafilatura.extract(downloaded, output_format="markdown", include_links=True, url=url)
if not text:
text = trafilatura.extract(downloaded, url=url)
if not text:
return f"Could not extract readable content from: {url}"
if len(text) > max_chars:
text = text[:max_chars] + f"\n\n[… truncated at {max_chars} chars — pass a larger max_chars (up to 131072) to see more]"
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",
@@ -96,10 +211,10 @@ DECLARATIONS = [
types.FunctionDeclaration(
name="http_fetch",
description=(
"Fetch a specific URL and return the response. Unlike web_search, this hits "
"Fetch a specific URL and return the raw response body. Unlike web_search, this hits "
"a direct URL — useful for health checks, JSON API endpoints, webhook testing, "
"or reading a specific page when you already know the URL. "
"Response body is capped at 8 KB."
"or inspecting raw page source. For readable article/doc content, use web_read instead. "
"Response body is capped at max_chars (default 8192, max 32768)."
),
parameters=types.Schema(
type=types.Type.OBJECT,
@@ -108,6 +223,43 @@ DECLARATIONS = [
"method": types.Schema(type=types.Type.STRING, description="HTTP method: GET (default), POST, HEAD"),
"body": types.Schema(type=types.Type.STRING, description="Optional request body (for POST requests)"),
"timeout": types.Schema(type=types.Type.INTEGER, description="Request timeout in seconds (default 15, max 60)"),
"max_chars": types.Schema(type=types.Type.INTEGER, description="Max characters to return (default 8192, max 131072)"),
},
required=["url"],
),
),
types.FunctionDeclaration(
name="web_read",
description=(
"Fetch a URL and extract clean readable text, stripping ads, navigation, sidebars, "
"and other boilerplate. Returns the main article/document content as markdown. "
"Use this for blog posts, documentation, news articles, GitHub READMEs, or any page "
"where you want the content without surrounding noise. "
"For raw HTTP responses (JSON APIs, health checks, source inspection), use http_fetch."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"url": types.Schema(type=types.Type.STRING, description="Full URL to fetch and extract"),
"max_chars": types.Schema(type=types.Type.INTEGER, description="Max characters to return (default 16000, max 131072)"),
},
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"],
),

View File

@@ -129,16 +129,24 @@ User-defined scheduled jobs stored in `home/{user}/persona/{name}/CRONS.json`. R
## Notification Channel Config
`notification_channel` in `channels.json` sets the default outbound channel for all proactive messages (distill alerts, cron message/brief jobs):
`notification_channel` in `channels.json` sets the default outbound channel for all proactive messages (distill alerts, cron jobs, reminder checks):
```json
{
"notification_channel": "nextcloud",
...
"notification_channel": "web_push",
"notification_email": "user@example.com",
"nextcloud": { "notification_room": "<token>" },
"google_chat": { "outbound_webhook": "https://..." }
}
```
If absent, defaults to `nextcloud` if configured. Currently only NC Talk is supported for outbound; Google Chat outbound is a future item.
Supported channels: `web_push` (browser push via VAPID), `email`, `nextcloud` (NC Talk), `google_chat`. Configured via **Settings → Notifications** (`/settings/notifications`).
**Proactive notification triggers:**
- **Daily 09:00** — `_run_reminder_check()` in `scheduler.py`: reads due/overdue reminders per persona, fires `notify()` with a formatted summary
- **Memory distillation** — `_run_mid()` / `_run_long()` call `notify()` on completion
- **Cron jobs** — `message` / `brief` job types call `notify()` directly
- **On-demand** — `POST /api/push/test` (test notification) and `POST /api/push/reminders/check` (immediate reminder check)
---

View File

@@ -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`.
@@ -46,27 +46,39 @@ Full API reference: [`docs/OPEN_WEBUI_API.md`](../docs/OPEN_WEBUI_API.md)
## 2. Orchestrator Tool Expansions
**Status:** Planned. Current tool count: 27. These fill obvious gaps.
**Status:** Ongoing. Current tool count: 45. Previously planned tools are all complete.
New tools for `cortex/tools/` — each follows the existing async pattern (implement function,
add `FunctionDeclaration`, register in `__init__.py`).
### Completed
All originally planned tools are live: `cortex_restart`, `cortex_logs`, `http_fetch`,
`file_list`, `file_write`, `nc_talk_send`, `email_send`, `web_push`, `agent_notes_*`.
| Tool | Module | Description |
|---|---|---|
| `cortex_restart` | `system.py` | `systemctl --user restart cortex` — Inara can apply her own config changes; returns last 10 log lines after restart |
| `cortex_logs` | `system.py` | `journalctl --user -u cortex -n N` — tail service logs for debugging |
| `http_fetch` | `web.py` | Fetch a specific URL and return content; for health checks, API probing, webhook testing — not a search, a direct GET/POST |
| `file_list` | `scratch.py` or new `files.py` | List files and directories at a path; currently only `file_read` exists |
| `file_write` | `files.py` | Write content to a file with a path allow-list (persona dir + scratch by default) |
| `nc_talk_send` | new `notify.py` | Proactively send a message to the user via Nextcloud Talk outbound API |
| `email_send` | `notify.py` | Send email via existing `email_utils.py` SMTP helper |
| `web_push` | `notify.py` | Browser push notification via Web Push API (requires push subscription stored per-user in `home/{user}/push_sub.json`; pairs with the PWA service worker) |
### Next additions
**Safety note for `cortex_restart`:** The service will drop in-flight SSE connections on restart.
Only call if no streaming response is active. Add a check or a short delay before restarting.
**Datetime note:** The current date and time is already injected into every system prompt
via `context_loader.py` (`--- System --- Current date and time: ...`). A dedicated
`datetime_now` tool is not needed — the timestamp is always in context.
**Safety note for `file_write`:** Enforce an allow-list at the tool level, not just in the prompt.
Default allow: `home/{user}/persona/{name}/` and `/tmp/cortex-scratch/`. Reject any path outside.
### Completed Round 2
| Tool | Notes |
|---|---|
| `session_search` | `tools/files.py` — full-text grep across session logs; params: `query`, `limit` (max 20); own sessions only via ContextVars. 2026-05-08 |
| `reminders due dates` | `tools/reminders.py` — optional `due: YYYY-MM-DD` on `reminders_add`; `load_due_reminders()` suppresses future-dated entries from context. 2026-05-08 |
| `spawn_agent` | `tools/agents.py` — sync sub-agent via role model; semaphore per host (`max_concurrent` in host schema); `asyncio.wait_for` timeout; admin-only. 2026-05-08 |
### Remaining Round 2
| Tool | Module | Priority | Description |
|---|---|---|---|
| `http_post` | `web.py` | Medium | POST to an external URL — for webhooks, REST APIs, form submissions. Requires a per-user host allowlist (same pattern as `email_send`) to prevent misuse. |
| `nc_talk_history` | `notify.py` | Medium | Read recent messages from a Nextcloud Talk conversation. The bot can send but cannot read — adding read capability gives it full context before replying. |
| `task_list` priority filter | `tasks.py` | Low | `task_list` accepts `status` but not `priority`. Add `priority` param so the agent can ask "what are my high-priority tasks?" without returning everything. |
| `http_fetch` max_chars | `web.py` | Low | Currently hardcapped at 8,192 chars. Accept optional `max_chars` param so callers can request more or less content. |
### Not needed / deferred
- **`datetime_now`** — already in system prompt (see note above)
- **`memory_read`** — memory files are already loaded into system prompt at Tier 2+; a tool adds no value except at Tier 1, which is a rare edge case
- **Calculator** — modern models handle arithmetic well; `shell_exec` covers edge cases for admins
- **Google Calendar** — useful but requires Google API OAuth scope expansion; defer until auth layer supports it
---
@@ -244,3 +256,289 @@ Rather than a single Cortex instance, each device in the fleet runs its own inst
- Session continuity — does a conversation that starts on one node stay there, or can it migrate?
The Syncthing-synced `home/` directory and shared `model_registry.json` already provide a natural foundation — instances share persona memory and context without a central DB.
---
## 11. LLM Wiki — Persistent Knowledge Compilation (Karpathy Pattern)
**Status:** Concept — no design yet. Inspired by [Karpathy's llm-wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) gist.
**Core idea:** Instead of treating AE Journals as an archive you retrieve from, evolve them into a **living wiki** that the LLM incrementally builds and maintains. When a new source is added, the LLM doesn't just index it — it reads it, extracts key information, and integrates it into the existing wiki: updating entity pages, revising topic summaries, flagging contradictions, strengthening or challenging the evolving synthesis. Knowledge is compiled once and kept current, not re-derived on every query.
This is a philosophical shift from our current approach (RAG/retrieval) toward **compounding knowledge** — the wiki gets richer with every source added and every question asked.
### Three-Layer Architecture
```
Raw Sources (immutable) ↓
→ LLM reads, extracts, cross-references
Wiki (LLM-maintained markdown) ← the persistent artifact
→ Human reads, LLM writes
Schema (CLAUDE.md / AGENTS.md) ← configuration + conventions
```
1. **Raw sources** — curated, immutable originals (articles, papers, session logs, transcripts). LLM reads from them, never modifies them.
2. **The wiki** — directory of LLM-generated markdown files: summaries, entity pages, concept pages, comparisons, synthesis. The LLM owns this layer entirely. Creates pages, updates them when new sources arrive, maintains cross-references.
3. **Schema** — a configuration document (analogous to our `PROTOCOLS.md`) that tells the LLM how the wiki is structured, what conventions to follow, and what workflows to use when ingesting sources or answering questions. Co-evolved with the human over time.
### Operations
**Ingest.** Drop a new source into the raw collection and tell the LLM to process it. Flow: LLM reads source → discusses key takeaways with human → writes summary page → updates index → updates relevant entity/concept pages (a single source might touch 10-15 pages) → appends to log. Human stays involved, guiding emphasis.
**Query.** Ask questions against the wiki. LLM reads the index to find relevant pages, drills in, synthesizes an answer with citations. **Key insight: good answers get filed back into the wiki as new pages.** A comparison table, an analysis, a connection discovered — these are valuable and shouldn't disappear into chat history.
**Lint.** Periodic health check: contradictions between pages, stale claims superseded by newer sources, orphan pages with no inbound links, missing cross-references, data gaps that could be filled with a web search.
### Index and Log (Two Navigation Files)
**`index.md`** — content-oriented catalog. Every wiki page listed with link, one-line summary, and optional metadata (date, source count). Organized by category. LLM updates on every ingest. At moderate scale (~100 sources, ~hundreds of pages), this replaces the need for embedding-based RAG.
**`log.md`** — chronological, append-only record of what happened and when (ingests, queries, lint passes). Each entry starts with a consistent prefix (e.g. `## [2026-04-02] ingest | Article Title`) making it parseable with simple tools like `grep "^## \[" log.md | tail -5`.
### Applicability to Cortex / Inara
This pattern maps naturally to several existing concepts:
| Karpathy Concept | Cortex Equivalent | Gap |
|---|---|---|
| Raw sources | Session logs, imported docs | No curated raw-source collection yet |
| Wiki pages | AE Journals | Journals are entry-based, not interlinked-wiki-based |
| Index + Log | No equivalent | Would need `wiki_index.md` and `wiki_log.md` |
| Schema/Protocols | PROTOCOLS.md, OPERATIONS.md | Not configured for wiki maintenance workflows |
| Lint operation | No equivalent | No periodic wiki health-check exists |
| Answers filed back | Session chat history | Answers are lost after session (unless distilled) |
| Obsidian as IDE | Cortex UI / Files panel | Files panel could serve as the browsing surface |
**Next steps (if pursued):**
1. Design the wiki directory structure within `agents_sync/` — separate from session logs and memory files
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
---
## 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

View File

@@ -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

View File

@@ -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`, `tasks`, `scratch`, `reminders`, `cron`, `system`, `notify`, `ae_journals`, `ae_tasks`, `agent_notes` |
| `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.

View File

@@ -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)

View File

@@ -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-06
> 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,20 +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 (40 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 — 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
---
@@ -76,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 |
---

View 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.

View File

@@ -1,7 +1,7 @@
# Cortex — Roadmap
> Phases and priorities. For active tasks see `TODO__Agents.md`.
> Last updated: 2026-04-29
> Last updated: 2026-05-09
---
@@ -39,7 +39,17 @@
- ✅ Session search (full-text across past session logs)
- ✅ Distill notifications (NC Talk after mid/long runs)
- ✅ Local backend for distillation (DISTILL_BACKEND_MID/LONG in .env)
- [ ] **Local orchestrator** — ReAct tool loop using local model (High priority — see `TODO__Agents.md`)
-Local orchestrator — OpenAI-compatible ReAct loop; fires when orchestrator role → local model
- ✅ Web push notifications — VAPID; `web_push` tool; PWA-installable; subscribe via ☰ menu
- ✅ Proactive notifications — daily reminder check (09:00); `notify()` routes to any configured channel; dedicated settings page
- ✅ 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

View 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 |

View File

@@ -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
@@ -55,8 +57,7 @@ automatically. Remaining work is quality/reliability parity, not ground-up desig
- `/manifest.json` and `/sw.js` served at root; added to `_PUBLIC` in auth_middleware
- Tested: install prompt confirmed working in Chromium
### [Tools] Orchestrator tool expansions
New tools for `cortex/tools/` — higher-value additions that fill obvious gaps.
### [Tools] Orchestrator tool expansions — Round 1 ✅
- [x] **`cortex_restart`** — detached subprocess, 5s delay, admin-only, confirm-required — 2026-04-29
- [x] **`cortex_logs`** — `journalctl --user -u cortex -n N`, admin-only — 2026-04-29
- [x] **`http_fetch`** — direct URL fetch via httpx, 8192 char cap — 2026-04-29
@@ -66,26 +67,149 @@ New tools for `cortex/tools/` — higher-value additions that fill obvious gaps.
- [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
### [Channel] Proactive notifications
Inara reaches out on her own initiative via NC Talk or Google Chat when a reminder
fires, a cron job completes, or something else warrants attention. The cron/reminder
infrastructure already exists — this closes the loop so she can interrupt the user.
- [ ] Add outbound message helper for NC Talk (`send_nextcloud_message(user, text)`)
- [ ] Add outbound message helper for Google Chat (`send_google_chat_message(user, text)`)
- [ ] Wire cron job completion and reminder triggers to call outbound helper
- [ ] Store user preference: which channel to use for proactive notifications
- [ ] `channels.json` already per-user — add `notify_channel: "nextcloud" | "google_chat" | null`
### [Agents] Multi-Level Agent Management
### [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
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.
**Note:** `datetime_now` is NOT needed — current date/time is already injected into every
system prompt by `context_loader.py` at all tiers.
- [x] **`session_search`** — expose existing session search to the orchestrator — 2026-05-08
- Wraps session log grep as a tool callable in `tools/files.py`
- Params: `query: str`, `limit: int = 5` (max 20)
- Returns: excerpts with session date, newest first; own sessions only via ContextVars
- User-level (no TOOL_ROLES entry needed)
- [x] **`reminders` due-date support** — make reminders time-aware — 2026-05-08
- Optional `due: YYYY-MM-DD` on `reminders_add`; stored as `due: date` first line of body
- `context_loader.py` calls `load_due_reminders()` — future-dated sections suppressed until due
- `reminders_list` shows `[OVERDUE]`, `[due TODAY]`, or `[due: YYYY-MM-DD]` per entry
- Backward compatible — existing undated reminders always surface as before
- [x] **`spawn_agent`** — spawn a synchronous sub-agent using any role's model + tools — 2026-05-08
- `cortex/tools/agents.py``spawn_agent(task, role, tier, timeout, max_rounds)`
- Per-host asyncio semaphore keyed by `host_id` (or model type for cloud); `max_concurrent` field in host schema
- Supports `local_openai` and `gemini_api` model types; returns error string for others
- Admin-only tool (powerful — can spawn arbitrarily long sub-tasks)
- Host UI: "Max parallel" number input in host edit/add forms
- [x] **`spawn_agent` per-call tool restrictions** — `allow_tools` and `deny_tools` params — 2026-05-12
- `allow_tools: list[str]` — intersected with role ceiling; cannot grant beyond role config
- `deny_tools: list[str]` — blocked even when role permits; falls back to `confirm_deny` gate when `tool_list` is None
- Both params documented in FunctionDeclaration for orchestrator use
- [x] **`file_diff`** — unified diff between two project-scoped files — 2026-05-12
- `cortex/tools/files.py``diff -u`, 50 KB output cap, project-scoped path resolution
- [x] **`git_status` / `git_log` / `git_diff`** — read-only git inspection — 2026-05-12
- `cortex/tools/git.py` — new module; all project-scoped, low risk
- `git_log(n, path, oneline)` — last N commits with optional path filter
- `git_diff(ref_a, ref_b, path, stat_only)` — any ref range; no args = unstaged vs HEAD
- [x] **`http_post`** — POST to external URLs — 2026-05-09
- Params: `url: str`, `body: str`, `headers: dict | None`, `max_chars: int`
- Per-user URL prefix allowlist in `home/{user}/http_allowlist.json` (JSON array of prefixes)
- 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
### [Channel] Proactive notifications ✅ — 2026-05-08
Inara reaches out on her own initiative via NC Talk, Google Chat, email, or browser push.
- [x] `notification.py``notify(username, message, channel=None)` routes to NCT / email / Google Chat / web_push
- [x] `web_push` added as a routable channel in `notification.py` (was tool-only before)
- [x] `scheduler.py``_run_reminder_check()` daily at 09:00: reads due reminders per persona, fires `notify()` with a summary
- [x] `cron_runner.py` — already calls `notify()` on job completion (was already wired)
- [x] `scheduler.py` — distill_mid and distill_long already call `notify()` on completion
- [x] Settings UI — "Browser Push Notification" option added to Notification Channel selector
- [x] `notification_channel` accepts `"web_push"` in `routers/settings.py`
- [x] `GET /settings/notifications` — dedicated Notifications page (channel form + test buttons); Settings page now shows a link card
- [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)
### [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
@@ -100,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
@@ -156,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
@@ -410,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