Compare commits

..

12 Commits

Author SHA1 Message Date
Scott Idem
c01ef663f5 fix: per-persona session/file isolation + onboarding route order
- session_store: store sessions under home/{user}/persona/{name}/session_data/
  instead of the shared cortex/data/sessions/ bucket
- chat endpoints: add user/persona query params to /sessions, /history/*,
  /sessions/*, /note so they resolve the correct persona context
- files router: add user/persona query params to /files and /files/{name}
  so the file browser loads the right persona's files
- app.js: pass user/persona on all session, history, and file fetches;
  move _fileParams to top-level scope so it is available everywhere
- onboarding: fix FastAPI route ordering — register /persona before /{token}
  so the literal path wins and does not get captured as a token value
- ui.py: read Emoji field from IDENTITY.md and inject into CORTEX_CONFIG
  so the header icon reflects each persona's chosen emoji
- .gitignore: exclude home/**/session_data/ (runtime state)
- migrate scott/inara sessions from cortex/data/sessions/ to session_data/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:01:07 -04:00
Scott Idem
99f8961bec feat(tina): add dinosaurs as a core Holly interest
Lifelong passion — Jurassic Park seen countless times. Tina should
engage with this genuinely, not just acknowledge it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:18:38 -04:00
Scott Idem
c2825194d4 docs: update project docs, NC Talk guide, Tina persona, and gitignore
- CLAUDE.md: add new auth/onboarding files to directory map, update
  security section (JWT/bcrypt/invite details), expand recently completed
- README.md: fix Web UI auth description, add User Management section
- TODO__Agents.md: mark NC Talk docs and auth/onboarding complete,
  update Holly onboarding plan to reflect single-instance multi-user approach
- docs/NEXTCLOUD_TALK_BOT.md: complete guide — occ commands, nginx config,
  clarify incoming vs outgoing HMAC difference, multi-user note, full
  troubleshooting table
- home/holly/persona/tina/: flesh out all four persona files with real
  content (DCC name origin, metal music, reading, foster cats, Holly's profile)
- .gitignore: exclude home/**/auth.json, invite.json, profile.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:13:35 -04:00
Scott Idem
8c61c28b7d fix: mount /static before ui.router to prevent wildcard route catching static files
The ui.router's /{username}/{persona} wildcard was matching /static/style.css
(username="static", persona="style.css") because app.mount("/static") was
registered after app.include_router(ui.router). FastAPI processes routes in
registration order, so /static must be mounted first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:28:13 -04:00
Scott Idem
69f38ca7dc feat: SMTP email support for invite links + profile.json for user email storage
- email_utils.py: send_email() via smtplib.SMTP_SSL (port 465, same server
  as AE API); send_invite_email() renders plain-text + HTML invite template
- config.py: smtp_server, smtp_port, smtp_username, smtp_password,
  smtp_from_email, smtp_from_name, cortex_base_url settings
- manage_passwords.py:
  - profile.json helpers (get/set email stored in home/{username}/profile.json)
  - invite command now accepts optional email arg, sends invite automatically;
    falls back to stored email; prints link either way
  - new 'email' command to store/update a user's email address
  - 'list' command now shows email alongside password status
- .env.default: SMTP_* and CORTEX_BASE_URL documented

Usage after adding SMTP_PASSWORD to .env:
  python manage_passwords.py invite holly holly@example.com
  → generates token, stores email, sends invite, prints link as fallback

All 80 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:19:09 -04:00
Scott Idem
46b65d087c feat: persona onboarding — invite tokens, self-service setup, persona creation, switcher
New user flow:
  1. Admin: python manage_passwords.py invite <username>  → generates URL
  2. User visits /setup/<token> → sets own password → logged in
  3. User redirected to /setup/persona → fills name/emoji/description
  4. persona_template.py generates all starter files → lands at /{user}/{persona}

Multiple personas:
  - Header persona name is now a clickable dropdown listing all personas
  - "New persona" link at bottom → /setup/persona (available to logged-in users)
  - /api/personas endpoint returns persona list for current session user

New files:
  - persona_template.py: generates IDENTITY/SOUL/PROTOCOLS/USER/HELP.md + data files
  - routers/onboarding.py: /setup/{token}, /setup/persona GET+POST
  - static/setup.html: two-step form (password → persona), emoji picker, mobile-friendly

Updated:
  - auth_utils.py: create_invite(), validate_invite(), consume_invite()
  - manage_passwords.py: invite command with URL output
  - auth_middleware.py: /setup/* prefix is public (invite tokens need no auth)
  - routers/ui.py: /api/personas endpoint; post-login redirect if no personas
  - static/app.js: persona switcher dropdown with navigation + Add persona link
  - static/style.css: .persona-switcher, .persona-dropdown, mobile adjustments

Mobile: login/setup pages are card-centered with responsive padding;
dropdown avoids edge-clipping on narrow screens; logout button stays visible.

All 80 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:10:32 -04:00
Scott Idem
a9bbb668b5 feat: session auth + per-user/persona UI at /{user}/{persona}
Replaces nginx basic auth with a proper per-user session system:

- auth_utils.py: bcrypt password hashing, JWT cookie creation/decode
- auth_middleware.py: validates JWT cookie on all routes except /login,
  /health, /static/, and webhook endpoints (/channels/, /webhook/)
- routers/ui.py: GET /login, POST /login, POST /logout,
  GET /{username}/{persona} — serves index.html with CORTEX_CONFIG injected
- static/login.html: minimal login form (dark theme, matches UI)
- main.py: registers SessionAuthMiddleware + ui.router
- config.py: jwt_secret, jwt_expire_days settings
- manage_passwords.py: CLI tool to set/check/list user passwords
- app.js: reads window.CORTEX_CONFIG (user + persona), sends both on
  every /chat and /orchestrate request; persona name shown in header;
  logout button (⏏) added to header
- requirements.txt: bcrypt, PyJWT, python-multipart
- .env.default: JWT_SECRET, JWT_EXPIRE_DAYS documented
- tests: client fixture injects JWT cookie; security test assertions
  updated for URL-normalized path traversal paths (still secure, codes differ)

All 80 tests pass.

Setup for a new user:
  python manage_passwords.py set scott
  python manage_passwords.py set holly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:54:12 -04:00
Scott Idem
77e770cdb2 feat: multi-user/multi-persona support with two-level home directory layout
Restructures persona storage from a flat personas/{name}/ layout to
home/{username}/persona/{name}/, mirroring Linux home directories.

Changes:
- persona.py: two ContextVars (user + persona), Linux-style name validation,
  set_context(), get_user(), get_persona(), validate(), list_users(),
  list_user_personas(); persona_path() takes (username, name)
- config.py: replaces personas_dir with home_dir + home_root()
- git mv personas/inara → home/scott/persona/inara (history preserved)
- home/holly/persona/tina/: Holly's persona stub added
- cron_runner.py: all storage functions take (username, persona) params
- tools/cron.py: stamps user + persona on jobs; APScheduler IDs are
  {user}:{persona}:{job_id} to prevent collisions across users
- memory_distiller.py: distill_short/mid/long take (username, persona);
  added missing Path + settings imports
- scheduler.py: _load_user_crons() iterates home/*/persona/* (two-level)
- routers/chat.py, orchestrator.py: user field added; set_context() called
- tests/conftest.py: home_root fixture with two-level structure;
  patches home_dir instead of personas_dir
- tests/test_persona.py: fully rewritten for two-level API
- tests/test_api_files.py: updated fixture name and path
- .env.default: documents HOME_DIR setting; scrubs stale API key
- CLAUDE.md, README.md: directory maps updated for new layout

All 80 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:35:40 -04:00
Scott Idem
92a8f5d894 test: add Cortex test suite (77 tests, no LLM calls)
Tests cover:
  - Smoke: /health, /auth/status, /distill/status (test_health.py)
  - Persona validation: path traversal, bad names, list_personas (test_persona.py)
  - Chat API: persona routing, session persistence, error handling (test_api_chat.py)
  - Files API: ALLOWED set enforcement, read/write, missing files (test_api_files.py)
  - Webhooks: NC Talk HMAC accept/reject, Google Chat JWT (test_webhooks.py)
  - Tools: scratch read/write/append/clear, tasks CRUD, cron parser + tools (test_tools.py)
  - Security: path traversal, replay attack, known gaps documented (test_security.py)

All LLM calls mocked — suite runs in ~1.4s.
Run: cd cortex && .venv/bin/pytest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:03:42 -04:00
Scott Idem
5cadb836fa feat: multi-persona support (single Cortex, multiple users)
- Add cortex/persona.py: ContextVar-based per-request routing with
  path traversal protection and persona validation
- Migrate inara/ → personas/inara/ (git history preserved via git mv)
- config.py: add personas_root(), inara_path() delegates to personas/inara
- All 14 settings.inara_path() call sites replaced with persona_path()
- ChatRequest + OrchestrateRequest: add persona field (default: "inara")
  with validation at request entry before any processing
- memory_distiller: add optional persona param for future per-persona distill
- cron_runner/tools/cron: stamp persona on jobs, prefix APScheduler IDs
  (persona:job_id) to prevent collisions across personas
- scheduler: _load_user_crons() iterates all personas at startup

Adding a new persona: create personas/<name>/ with IDENTITY.md + SOUL.md.
Auth: handled at nginx level (inject X-Cortex-Persona header per subdomain).
Future: persona maps to Aether account_id_random for full integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:50:02 -04:00
Scott Idem
6316ffa1d4 feat: cron job system for Inara (remind + note types)
- cron_runner.py: job storage (CRONS.json), schedule parsing, execution
- tools/cron.py: cron_list/add/remove/toggle + reminders_clear tools
- scheduler.py: load user crons at startup, expose get_scheduler() for
  live add/remove without restarts
- context_loader.py: auto-include REMINDERS.md in system prompt (tier 2+)
  so cron reminders surface automatically without Inara having to poll
- inara/CRONS.json + REMINDERS.md: backing files (initially empty)

Schedule formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM
Job types: remind (→ REMINDERS.md) | note (→ SCRATCH.md)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:17:49 -04:00
Scott Idem
1b32667872 feat: scratchpad tool + fix Claude auth token expiry warning
- Add cortex/tools/scratch.py with scratch_read/write/append/clear tools
- Register all four scratch tools in the orchestrator tool registry
- Create inara/SCRATCH.md as the backing file (never distilled/archived)
- Fix auth.py: expiresAt reflects short-lived access token (~8h) not the
  1-year refresh token — suppress expiry warning when refreshToken is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:10:03 -04:00
84 changed files with 4512 additions and 380 deletions

View File

@@ -2,16 +2,42 @@
# DO NOT commit .env — it contains secrets
# ── Agent identity ───────────────────────────────────────────────────────────
# Each running instance has its own identity directory and name.
# For a second instance (e.g. Holly), copy this file, change these values,
# set a different PORT and INARA_DIR, and run a separate systemd unit.
# Global display names used in distillation prompts and session logs.
# Individual persona identities live in home/{username}/persona/{name}/IDENTITY.md
AGENT_NAME=Inara
USER_NAME=Scott
# ── Home directory ────────────────────────────────────────────────────────────
# Root for all user/persona data. Layout: home/{username}/persona/{name}/
# Relative paths are resolved from the cortex/ directory.
# Default: ../home (i.e. Cortex_and_Inara_dev/home/)
# HOME_DIR=../home
# ── Session auth ─────────────────────────────────────────────────────────────
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
JWT_SECRET=change-me-in-dotenv
JWT_EXPIRE_DAYS=30
# ── SMTP (invite emails + future notifications) ───────────────────────────────
SMTP_SERVER=linode.oneskyit.com
SMTP_PORT=465
SMTP_USERNAME=send_mail
SMTP_PASSWORD=
SMTP_FROM_EMAIL=noreply@oneskyit.com
SMTP_FROM_NAME=Cortex
# Base URL included in invite links
CORTEX_BASE_URL=https://cortex.dgrzone.com
# ── Server ──────────────────────────────────────────────────────────────────
HOST=0.0.0.0
PORT=8000
# ── Google Chat bot ──────────────────────────────────────────────────────────
# JWT audience for verifying inbound Workspace Add-on Chat webhook requests.
# For Workspace Add-on Chat apps, the aud claim = the endpoint URL.
# Leave blank to disable verification (dev/testing only).
GOOGLE_CHAT_AUDIENCE=https://cortex.dgrzone.com/channels/google-chat
# ── Nextcloud Talk bot ───────────────────────────────────────────────────────
NEXTCLOUD_URL=https://cloud.dgrzone.com
NEXTCLOUD_TALK_BOT_SECRET=
@@ -27,7 +53,7 @@ TIMEOUT_GEMINI=120
# ── Orchestrator (Gemini API — not Gemini CLI) ───────────────────────────────
# Required for /orchestrate endpoint and tool use
# Free tier key: https://aistudio.google.com/apikey
GEMINI_API_KEY=AIzaSyAnmzm31zO1kFkphxCkTnwgFizbfgB1JHI
GEMINI_API_KEY=
# Model for the orchestration tool loop (not the user-facing response)
ORCHESTRATOR_MODEL=gemini-2.5-flash

6
.gitignore vendored
View File

@@ -8,6 +8,12 @@ __pycache__/
# Session data (runtime state, not source)
cortex/data/
home/**/session_data/
# User credentials and tokens — never commit
home/**/auth.json
home/**/invite.json
home/**/profile.json
# Syncthing Metadata
.stfolder/

View File

@@ -21,41 +21,64 @@ Cortex_and_Inara_dev/
cortex/ ← FastAPI service (the dispatcher)
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
orchestrator_engine.py ← Gemini API ReAct tool loop → Claude handoff
context_loader.py ← Loads Inara's system prompt from inara/ files
context_loader.py ← Builds system prompt from persona files (tier 14)
session_store.py ← In-memory + file session persistence
session_logger.py ← Writes session turns to inara/sessions/
session_logger.py ← Writes session turns to home/{user}/persona/{name}/sessions/
memory_distiller.py ← Short/mid/long distill jobs (APScheduler)
scheduler.py ← APScheduler setup
cron_runner.py ← Cron job storage, schedule parsing, job execution
scheduler.py ← APScheduler setup (distill + user crons)
event_bus.py ← Internal SSE pub/sub (NC Talk → browser)
auth_utils.py ← bcrypt passwords, JWT create/decode, invite token system
auth_middleware.py ← SessionAuthMiddleware — JWT cookie validation on all routes
persona_template.py ← Bootstrap a new persona directory from string templates
email_utils.py ← SMTP_SSL email helpers (invite emails, future notifications)
routers/
chat.py ← POST /chat (streaming SSE)
orchestrator.py ← POST /orchestrate, GET /orchestrate/{job_id}
auth.py ← GET /auth/status (Claude + Gemini CLI token checks)
distill.py ← POST /distill/*, GET /distill/status
files.py ← GET /files (inara/ file browser)
files.py ← GET /files (persona file browser)
nextcloud_talk.py ← POST /webhook/nextcloud (NC Talk bot)
google_chat.py ← POST /webhook/google (Google Chat — stub)
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
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)
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)
login.html — login form (dark theme, POST /login)
setup.html — onboarding form (password + persona creation)
data/sessions/ ← Persisted session JSON files
inara/Inara identity, memory, context files
IDENTITY.md ← Who Inara is
SOUL.md ← Values, personality, voice
PROTOCOLS.md ← Behavioral rules
CONTEXT_TIERS.md ← What each tier (13) includes in the system prompt
USER.md ← Scott's profile (loaded into context)
HELP.md ← In-app help content (rendered in UI)
MEMORY.md ← Persistent facts (written by distiller or manually)
MEMORY_SHORT.md ← Rolling short-term memory (auto-distilled daily)
MEMORY_MID.md ← Mid-term memory (auto-distilled weekly)
MEMORY_LONG.md ← Long-term memory (auto-distilled monthly)
sessions/ ← Session turn logs (YYYY-MM-DD_<id>.md)
home/ User and persona data (Linux home layout)
scott/
persona/
inara/ ← Inara identity, memory, context, sessions
IDENTITY.md ← Who Inara is
SOUL.md ← Values, personality, voice
PROTOCOLS.md ← Behavioral rules
CONTEXT_TIERS.md ← What each tier (14) includes in the system prompt
USER.md Scott's profile (loaded into context)
HELP.md ← In-app help content (rendered in UI)
MEMORY_LONG.md ← Long-term memory (auto-distilled monthly)
MEMORY_MID.md ← Mid-term memory (auto-distilled weekly)
MEMORY_SHORT.md ← Short-term memory (auto-distilled daily)
REMINDERS.md ← Pending reminders (auto-surfaced in context at tier 2+)
SCRATCH.md ← Ephemeral scratchpad
TASKS.json ← Personal task list
CRONS.json ← Scheduled jobs
sessions/ ← Session turn logs (YYYY-MM-DD.md)
holly/
persona/
tina/ ← Tina (Holly's persona) — same structure as inara/
docs/ ← Integration reference docs
NEXTCLOUD_TALK_BOT.md
@@ -130,7 +153,11 @@ http://localhost:8000/docs
- **Never `rm`** — move files to `~/tmp/gemini_trash`
- **Never commit secrets** — `.env` is gitignored; use `.env.default` as the reference
- `NEXTCLOUD_TALK_BOT_SECRET` and `GEMINI_API_KEY` live in `.env` only
- Cortex should only be accessible via WireGuard — never internet-exposed without VPN
- `/channels/*` and `/health` are publicly exposed (webhook auth is handled at app layer — JWT/HMAC)
- `/login`, `/logout`, `/setup/*`, `/static/*` are public — all other routes require a valid JWT session cookie
- `SessionAuthMiddleware` (`auth_middleware.py`) validates the cookie on every request; browsers are redirected to `/login`, API calls get 401
- Passwords are bcrypt-hashed and stored in `home/{username}/auth.json` — never in `.env` or the DB
- Invite tokens are one-time-use, 72-hour expiry, stored in `home/{username}/invite.json`
---
@@ -184,13 +211,37 @@ clearly asked for a directory to be unblocked.
---
## Active Tasks
## Current State (2026-03-20)
See `documentation/TODO__Agents.md` for the current task list.
High priority items as of 2026-03-18:
- Ollama backend (third LLM option — local, no API cost)
- NC Talk integration stabilization
- Knowledge consolidation (markdown → AE Journals)
Cortex is running and stable. All three primary channels are live:
| Channel | Status | Notes |
|---|---|---|
| Web UI | ✅ Live | `https://cortex.dgrzone.com` (basic auth) |
| Nextcloud Talk | ✅ Live | HMAC-signed webhook, async reply |
| Google Chat | ✅ Live | Workspace Add-on, `hostAppDataAction` response format |
### Active Tasks
See `documentation/TODO__Agents.md` for the full list. Current priorities:
- **[High]** Ollama backend — local LLM via `scott_gaming` over WireGuard
- **[Medium]** NC Talk — complete bot registration docs (`docs/NEXTCLOUD_TALK_BOT.md`)
- **[Medium]** Knowledge consolidation — markdown → AE Journals
### Recently Completed
- ✅ Session auth — bcrypt passwords, JWT cookies, login/logout, `SessionAuthMiddleware` — 2026-03-20
- ✅ Persona onboarding — invite tokens, self-service password setup, persona creation form — 2026-03-20
- ✅ Multi-persona switcher — dropdown in UI header, `/api/personas` endpoint — 2026-03-20
- ✅ SMTP invite email — `noreply@oneskyit.com`, HTML + plain text, `manage_passwords.py invite` — 2026-03-20
- ✅ CSS routing fix — `/static/*` mount must precede wildcard `/{user}/{persona}` route — 2026-03-20
- ✅ Multi-user/multi-persona support (`home/{username}/persona/{name}/` two-level layout) — 2026-03-20
- ✅ Scratchpad, task management, and cron/scheduled job tools — 2026-03-20
- ✅ Test suite (80 tests) covering API, persona routing, tools, security — 2026-03-20
- ✅ Google Chat bot (Workspace Add-on, JWT auth, `hostAppDataAction` format) — 2026-03-20
- ✅ Orchestrator Agent mode UI + session persistence — 2026-03-18
- ✅ Memory distiller (APScheduler, short/mid/long) — 2026-03
---

View File

@@ -6,7 +6,7 @@
> *"You can't stop the signal."*
Cortex is a self-hosted multi-agent orchestration layer. Inara is the primary conversational agent that lives inside it.
Cortex is a self-hosted multi-agent AI platform. It supports multiple users, each with their own named AI persona. Inara (Scott's persona) and Tina (Holly's persona) are the initial instances.
---
@@ -15,12 +15,39 @@ Cortex is a self-hosted multi-agent orchestration layer. Inara is the primary co
| Directory | What it is |
|---|---|
| `cortex/` | FastAPI service — dispatcher, routing, LLM backends, session management |
| `inara/` | Inara identity, memory, context, and help files |
| `home/` | User and persona data (`home/{username}/persona/{name}/`) |
| `home/scott/persona/inara/` | Inara identity, memory, and context files |
| `home/holly/persona/tina/` | Tina identity, memory, and context files |
| `docs/` | Integration reference docs (NC Talk bot, etc.) |
| `documentation/` | Architecture decisions, project plans, agent task lists |
---
## Multi-User Layout
Persona data lives in a two-level tree modelled on Linux home directories:
```
home/
scott/
persona/
inara/ ← IDENTITY.md, SOUL.md, MEMORY_*.md, sessions/, TASKS.json, …
holly/
persona/
tina/
[username]/
persona/
[name]/
```
Each HTTP request includes `user` and `persona` fields. The service validates both against
the `home/` tree before routing. ContextVars ensure per-request isolation in async code.
**Naming rules** (same as Linux usernames): lowercase letters, digits, `_`, `-`; must start
with a letter or underscore; max 32 characters. Example: `scott`, `holly`, `my_ai-v2`.
---
## Running Cortex
Cortex runs as a **systemd user service** (no sudo required).
@@ -53,9 +80,9 @@ Config lives in `cortex/config.py` and a `.env` file at the project root (not tr
| `documentation/TODO__Agents.md` | Active task list — read first |
| `documentation/ARCH__Intelligence_Layer.md` | Intelligence layer architecture (orchestrator, dev agents, knowledge) |
| `docs/NEXTCLOUD_TALK_BOT.md` | NC Talk bot setup |
| `inara/IDENTITY.md` | Inara persona and identity |
| `inara/HELP.md` | In-app help content (rendered in UI) |
| `inara/PROTOCOLS.md` | Inara behavioral protocols |
| `home/scott/persona/inara/IDENTITY.md` | Inara persona and identity |
| `home/scott/persona/inara/HELP.md` | In-app help content (rendered in UI) |
| `home/scott/persona/inara/PROTOCOLS.md` | Inara behavioral protocols |
| `~/agents_sync/projects/CORTEX.md` | High-level project vision and phases |
---
@@ -66,23 +93,75 @@ Config lives in `cortex/config.py` and a `.env` file at the project root (not tr
[User / Cron / Webhook]
Cortex Dispatcher (FastAPI, cortex/)
├─ POST /chat — direct to LLM (streaming SSE)
├─ POST /orchestrate — Gemini tool loop → Claude response
├─ POST /webhook/nextcloud — Nextcloud Talk bot
└─ POST /webhook/google — Google Chat Add-on
LLM Backend(s)
• Claude CLI — primary reasoning, coding, long-context
• Gemini CLI — secondary / cost routing
• Gemini API — orchestrator tool loop (separate from Gemini CLI)
• Ollama — offline/private (scott_gaming, future)
Inara (identity + memory in inara/)
Persona context loaded from home/{user}/persona/{name}/
```
See `documentation/ARCH__Intelligence_Layer.md` for the evolving orchestrator/responder and dev-agent architecture.
See `documentation/ARCH__Intelligence_Layer.md` for the orchestrator/responder and dev-agent architecture.
---
## Inara
## Inara / Tina
Inara is not tied to a specific model. The name is fixed; the backend may vary.
Her identity and behavioral files live in `inara/` and are loaded at startup via `cortex/context_loader.py`.
Each persona has its own identity, memory, and session history.
They are not tied to a specific LLM model — the name is fixed, the backend varies.
Context is loaded at request time from `home/{user}/persona/{name}/` via `cortex/context_loader.py`.
| User | Persona | Description |
|---|---|---|
| scott | inara | Scott's primary AI assistant |
| holly | tina | Holly's primary AI assistant |
---
## Channels
| Channel | Status | Notes |
|---|---|---|
| Web UI | Live | `https://cortex.dgrzone.com` — session auth (login form + JWT cookie) |
| Nextcloud Talk | Live | HMAC-signed webhook, async reply |
| Google Chat | Live | Workspace Add-on, JWT auth |
---
## User Management
```bash
cd cortex
# Create a user directory and send an invite email
.venv/bin/python manage_passwords.py invite <username> <email>
# List users with password and email status
.venv/bin/python manage_passwords.py list
# Set/check a password directly
.venv/bin/python manage_passwords.py set <username>
.venv/bin/python manage_passwords.py check <username>
```
New users receive a link to `/setup/{token}` where they set their own password and create their first persona. Invite tokens expire in 72 hours and are one-time-use.
---
## Testing
```bash
cd cortex
.venv/bin/python -m pytest tests/ -q
```
80 tests covering API endpoints, persona routing, tool functions, and security.
---

51
cortex/auth_middleware.py Normal file
View File

@@ -0,0 +1,51 @@
"""
Session auth middleware.
Validates the JWT cookie on every request. Unprotected paths are explicitly
listed in _PUBLIC. Webhook endpoints have their own auth (HMAC/JWT) so they
are also excluded.
Sets request.state.session_user to the authenticated username so downstream
routers can enforce ownership without re-reading the cookie.
"""
import jwt
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import RedirectResponse, JSONResponse
from auth_utils import COOKIE_NAME, decode_token
# Paths that don't require a session cookie
_PUBLIC = {"/login", "/logout", "/health"}
# Path prefixes that are always public (setup flow + webhooks)
_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/")
class SessionAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Always allow public paths and setup/webhook prefixes
if path in _PUBLIC or any(path.startswith(p) for p in _PUBLIC_PREFIXES):
return await call_next(request)
# Allow static assets without a cookie
if path.startswith("/static/"):
return await call_next(request)
# Validate session cookie
token = request.cookies.get(COOKIE_NAME)
if token:
try:
request.state.session_user = decode_token(token)
return await call_next(request)
except jwt.InvalidTokenError:
pass
# No valid session — redirect browser requests, 401 for API/JSON
accept = request.headers.get("accept", "")
if "text/html" in accept:
return RedirectResponse("/login", status_code=302)
return JSONResponse({"detail": "Not authenticated"}, status_code=401)

138
cortex/auth_utils.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Authentication utilities — password hashing and JWT session tokens.
Passwords are stored as bcrypt hashes in home/{username}/auth.json.
Sessions are JWT cookies signed with JWT_SECRET from settings.
Usage:
set_password("scott", "mypassword") # admin setup
check_credentials("scott", "mypassword") # login validation
create_token("scott") # returns JWT string
decode_token(token) # returns username or raises
"""
import json
import logging
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
import bcrypt
import jwt
from config import settings
logger = logging.getLogger(__name__)
COOKIE_NAME = "cortex_session"
ALGORITHM = "HS256"
# ---------------------------------------------------------------------------
# Password helpers
# ---------------------------------------------------------------------------
def _auth_path(username: str) -> Path:
return settings.home_root() / username / "auth.json"
def set_password(username: str, password: str) -> None:
"""Hash and store a password for a user. Creates auth.json if needed."""
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
_auth_path(username).write_text(json.dumps({"password_hash": hashed}) + "\n")
logger.info("password set for user: %s", username)
def check_credentials(username: str, password: str) -> bool:
"""Return True if username+password are valid, False otherwise."""
path = _auth_path(username)
if not path.exists():
return False
try:
data = json.loads(path.read_text())
stored = data.get("password_hash", "").encode()
return bcrypt.checkpw(password.encode(), stored)
except Exception:
return False
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
def create_token(username: str) -> str:
"""Return a signed JWT encoding the username."""
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_expire_days)
payload = {"sub": username, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> str:
"""Decode a JWT and return the username. Raises jwt.InvalidTokenError on failure."""
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
return payload["sub"]
# ---------------------------------------------------------------------------
# Invite tokens — one-time setup links for new users
# ---------------------------------------------------------------------------
def _invite_path(username: str) -> Path:
return settings.home_root() / username / "invite.json"
def create_invite(username: str, expire_hours: int = 72) -> str:
"""
Generate a one-time invite token for a user and save it to invite.json.
Returns the raw token string (embed in a URL).
"""
token = secrets.token_urlsafe(32)
expires = (datetime.now(timezone.utc) + timedelta(hours=expire_hours)).isoformat()
user_dir = settings.home_root() / username
user_dir.mkdir(parents=True, exist_ok=True)
_invite_path(username).write_text(
json.dumps({"token": token, "expires_at": expires, "used": False}) + "\n"
)
logger.info("invite created for user: %s (expires %s)", username, expires[:10])
return token
def validate_invite(token: str) -> str | None:
"""
Check an invite token across all users.
Returns the username if valid and unused, None otherwise.
"""
root = settings.home_root()
if not root.exists():
return None
for user_dir in root.iterdir():
if not user_dir.is_dir():
continue
inv_path = user_dir / "invite.json"
if not inv_path.exists():
continue
try:
data = json.loads(inv_path.read_text())
except Exception:
continue
if data.get("used"):
continue
if data.get("token") != token:
continue
expires = datetime.fromisoformat(data["expires_at"])
if datetime.now(timezone.utc) > expires:
continue
return user_dir.name
return None
def consume_invite(username: str) -> None:
"""Mark the invite token for a user as used."""
path = _invite_path(username)
if path.exists():
try:
data = json.loads(path.read_text())
data["used"] = True
path.write_text(json.dumps(data) + "\n")
except Exception:
pass

View File

@@ -27,7 +27,7 @@ class Settings(BaseSettings):
agent_name: str = "Inara"
user_name: str = "Scott"
inara_dir: Path = Path("../inara")
home_dir: Path = Path("../home")
sessions_dir: Path = Path("./data/sessions")
default_model: str = "claude-sonnet-4-6"
default_tier: int = 2
@@ -39,6 +39,11 @@ class Settings(BaseSettings):
timeout_gemini: int = 120 # frequently slow under load
timeout_local: int = 300 # local models may need to load first
# Google Chat
# JWT audience (aud) claim to verify on inbound webhook requests.
# Google Chat sets aud = the Google Cloud project number (e.g. "741112865538").
# Set to "" to disable verification (dev/testing only).
google_chat_audience: str = ""
# Google Chat must receive a response within 30s or shows an error to the user
google_chat_timeout: int = 25
# Backend forced for Google Chat — Claude is more reliable within the 25s deadline
@@ -63,16 +68,30 @@ class Settings(BaseSettings):
memory_budget_mid: int = 2000
memory_budget_short: int = 3000
# Session auth
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
jwt_expire_days: int = 30
# SMTP — for sending invite emails
smtp_server: str = ""
smtp_port: int = 465
smtp_username: str = ""
smtp_password: str = ""
smtp_from_email: str = "noreply@oneskyit.com"
smtp_from_name: str = "Cortex"
# Base URL used in invite links (no trailing slash)
cortex_base_url: str = "https://cortex.dgrzone.com"
host: str = "0.0.0.0"
port: int = 8000
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
def inara_path(self) -> Path:
"""Resolve inara_dir relative to this file's location if not absolute."""
if self.inara_dir.is_absolute():
return self.inara_dir
return (Path(__file__).parent / self.inara_dir).resolve()
def home_root(self) -> Path:
"""Resolve home_dir relative to this file's location if not absolute."""
if self.home_dir.is_absolute():
return self.home_dir
return (Path(__file__).parent / self.home_dir).resolve()
def sessions_path(self) -> Path:
"""Resolve sessions_dir relative to this file's location if not absolute."""

View File

@@ -1,5 +1,4 @@
from pathlib import Path
from config import settings
from persona import persona_path
# Core identity files — always loaded regardless of tier
@@ -26,7 +25,7 @@ def load_context(
Tier 3 — + last 2 raw session logs (~15,000 tokens)
Tier 4 — + last 7 raw session logs (~50,000 tokens)
"""
inara_dir = settings.inara_path()
inara_dir = persona_path()
parts = []
# ── 1. Core identity (always) ──────────────────────────────────
@@ -57,7 +56,15 @@ def load_context(
if help_path.exists():
parts.append(f"--- HELP.md ---\n{help_path.read_text()}")
# ── 4. Tiered memory — long → mid → short ─────────────────────
# ── 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}")
# ── 5. Tiered memory — long → mid → short ─────────────────────
# Short is last so it sits closest to the conversation turn.
if include_long:
# Fall back to legacy MEMORY.md during/after migration
@@ -81,7 +88,7 @@ def load_context(
if "Not yet populated" not in content:
parts.append(f"--- MEMORY_SHORT.md ---\n{content}")
# ── 5. Raw session logs (tier 3+) ──────────────────────────────
# ── 6. Raw session logs (tier 3+) ──────────────────────────────
if tier >= 3:
sessions_dir = inara_dir / "sessions"
if sessions_dir.exists():

164
cortex/cron_runner.py Normal file
View File

@@ -0,0 +1,164 @@
"""
Cron job storage and execution.
Handles reading/writing CRONS.json and running jobs when they fire.
Imported by scheduler.py (to load jobs at startup) and tools/cron.py
(to add/remove jobs at runtime).
Job schema:
{
"id": "c_abc123",
"label": "Human-readable name",
"schedule": "daily:09:00", # see parse_schedule() for all formats
"type": "remind" | "note",
"payload": "Text to write when the job fires",
"enabled": true,
"created_at": "ISO 8601",
"last_run": null | "ISO 8601"
}
Job types:
remind → appends to inara/REMINDERS.md (auto-loaded into Inara's context)
note → appends to inara/SCRATCH.md (read on demand via scratch_read)
"""
import logging
from datetime import datetime, timezone
from pathlib import Path
from persona import persona_path as _persona_path
logger = logging.getLogger(__name__)
_DEFAULT_HOUR = 9
_DEFAULT_MINUTE = 0
_DOW = {
"mon": "mon", "tue": "tue", "wed": "wed", "thu": "thu",
"fri": "fri", "sat": "sat", "sun": "sun",
"monday": "mon", "tuesday": "tue", "wednesday": "wed",
"thursday": "thu", "friday": "fri", "saturday": "sat", "sunday": "sun",
}
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
def crons_path(username: str | None = None, persona: str | None = None) -> Path:
return _persona_path(username, persona) / "CRONS.json"
def load_crons(username: str | None = None, persona: str | None = None) -> list[dict]:
p = crons_path(username, persona)
if not p.exists():
return []
try:
import json
return json.loads(p.read_text())
except Exception:
return []
def save_crons(crons: list[dict],
username: str | None = None,
persona: str | None = None) -> None:
import json
crons_path(username, persona).write_text(json.dumps(crons, indent=2) + "\n")
# ---------------------------------------------------------------------------
# Schedule parsing
# ---------------------------------------------------------------------------
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
"""
s = schedule.strip().lower()
if s == "hourly":
return {"minute": 0}
if s == "daily":
return {"hour": _DEFAULT_HOUR, "minute": _DEFAULT_MINUTE}
if s.startswith("daily:"):
h, m = _parse_hhmm(s[6:], schedule)
return {"hour": h, "minute": m}
if s.startswith("weekly:"):
rest = s[7:].split(":")
dow = _DOW.get(rest[0])
if not dow:
raise ValueError(
f"Unknown day of week {rest[0]!r}. "
f"Use: mon tue wed thu fri sat sun"
)
if len(rest) == 3:
h, m = _parse_hhmm(f"{rest[1]}:{rest[2]}", schedule)
else:
h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE
return {"day_of_week": dow, "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"
)
def _parse_hhmm(s: str, original: str) -> tuple[int, int]:
parts = s.split(":")
if len(parts) != 2:
raise ValueError(f"Expected HH:MM in {original!r}, got {s!r}")
return int(parts[0]), int(parts[1])
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
def _now_label() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
async def run_job(job: dict) -> None:
"""Execute a cron job. Called by APScheduler when the job fires."""
job_type = job.get("type")
payload = job.get("payload", "").strip()
label = job.get("label", job.get("id", "cron"))
section = f"\n## {label}{_now_label()}\n\n{payload}\n"
p_root = _persona_path(job.get("user"), job.get("persona"))
if job_type == "remind":
p = p_root / "REMINDERS.md"
existing = p.read_text() if p.exists() else ""
p.write_text(existing.rstrip() + "\n" + section)
logger.info("cron [remind] fired: %s", label)
elif job_type == "note":
p = p_root / "SCRATCH.md"
existing = p.read_text() if p.exists() else ""
p.write_text(existing.rstrip() + "\n" + section)
logger.info("cron [note] fired: %s", label)
else:
logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id"))
return
# Record last_run in the right persona's CRONS.json
u, p = job.get("user"), job.get("persona")
crons = load_crons(u, p)
for c in crons:
if c["id"] == job["id"]:
c["last_run"] = datetime.now(timezone.utc).isoformat()
break
save_crons(crons, u, p)

107
cortex/email_utils.py Normal file
View File

@@ -0,0 +1,107 @@
"""
Email utilities for Cortex — invite links and future notifications.
Uses smtplib.SMTP_SSL (port 465). Both plain-text and HTML bodies are sent.
SMTP credentials come from config.settings (set in .env).
"""
import logging
import smtplib
import ssl
from email.headerregistry import Address
from email.message import EmailMessage
from config import settings
logger = logging.getLogger(__name__)
def send_email(
to_email: str,
subject: str,
body_html: str,
body_text: str,
to_name: str = "",
) -> bool:
"""
Send an email via SMTP_SSL.
Returns True on success, False on any failure.
Logs errors but never raises — callers can check the return value.
"""
if not settings.smtp_server:
logger.error("SMTP not configured (SMTP_SERVER is empty)")
return False
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = Address(
display_name=settings.smtp_from_name,
addr_spec=settings.smtp_from_email,
)
msg["To"] = Address(
display_name=to_name or to_email,
addr_spec=to_email,
)
msg.set_content(body_text)
msg.add_alternative(f"<html><body>{body_html}</body></html>", subtype="html")
logger.info("sending email to %s%s", to_email, subject)
try:
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.smtp_server, settings.smtp_port, context=ctx) as server:
if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(msg)
logger.info("email sent to %s", to_email)
return True
except Exception as e:
logger.error("failed to send email to %s: %s", to_email, e)
return False
def send_invite_email(to_email: str, username: str, token: str, to_name: str = "") -> bool:
"""Send a Cortex invite link to a new user."""
url = f"{settings.cortex_base_url}/setup/{token}"
body_text = f"""\
You've been invited to Cortex.
Click the link below to set your password and create your persona:
{url}
This link expires in 72 hours and can only be used once.
— Cortex
"""
body_html = f"""\
<p>You've been invited to <strong>Cortex</strong>.</p>
<p>Click the link below to set your password and create your persona:</p>
<p><a href="{url}" style="
display:inline-block;
padding:10px 20px;
background:#7c3aed;
color:#fff;
text-decoration:none;
border-radius:6px;
font-family:sans-serif;
font-size:15px;
">Set up my account →</a></p>
<p style="font-size:13px;color:#666;">
Or copy this link:<br>
<code>{url}</code>
</p>
<p style="font-size:12px;color:#999;">
This link expires in 72 hours and can only be used once.
</p>
"""
return send_email(
to_email=to_email,
subject="You've been invited to Cortex",
body_html=body_html,
body_text=body_text,
to_name=to_name or username,
)

View File

@@ -2,13 +2,14 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import uvicorn
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
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
@asynccontextmanager
@@ -23,6 +24,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Cortex Dispatcher", lifespan=lifespan)
app.add_middleware(SessionAuthMiddleware)
# API routers
app.include_router(chat.router)
app.include_router(google_chat.router)
app.include_router(nextcloud_talk.router)
@@ -30,12 +34,16 @@ app.include_router(files.router)
app.include_router(distill.router)
app.include_router(auth.router)
app.include_router(orchestrator.router)
# Static files — must be mounted BEFORE ui.router so /static/* is matched first.
# ui.router has a wildcard /{username}/{persona} that would otherwise catch /static/style.css etc.
app.mount("/static", StaticFiles(directory="static"), name="static")
# Onboarding (invite tokens + persona creation — before ui.router)
app.include_router(onboarding.router)
@app.get("/")
async def index() -> FileResponse:
return FileResponse("static/index.html")
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
app.include_router(ui.router)
@app.get("/health")

173
cortex/manage_passwords.py Normal file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Password and invite management for Cortex users.
Usage:
python manage_passwords.py set <username> # prompt for password
python manage_passwords.py set <username> <pass> # set directly (avoid in shell history)
python manage_passwords.py check <username> # test a password interactively
python manage_passwords.py list # show users, passwords, and emails
python manage_passwords.py invite <username> [email] # generate + optionally email invite link
python manage_passwords.py email <username> <email> # store/update an email address
"""
import json
import sys
import getpass
# Add cortex/ to path so we can import config and auth_utils
sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent))
from auth_utils import set_password, check_credentials, _auth_path, create_invite
from persona import list_users
from config import settings
# ---------------------------------------------------------------------------
# Profile helpers (home/{username}/profile.json)
# ---------------------------------------------------------------------------
def _profile_path(username: str):
return settings.home_root() / username / "profile.json"
def get_profile(username: str) -> dict:
p = _profile_path(username)
if not p.exists():
return {}
try:
return json.loads(p.read_text())
except Exception:
return {}
def save_profile(username: str, profile: dict) -> None:
p = _profile_path(username)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(profile, indent=2) + "\n")
def get_email(username: str) -> str | None:
return get_profile(username).get("email")
def set_email(username: str, email: str) -> None:
profile = get_profile(username)
profile["email"] = email
save_profile(username, profile)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_set(args):
if not args:
print("Usage: manage_passwords.py set <username> [password]")
sys.exit(1)
username = args[0]
if len(args) >= 2:
password = args[1]
else:
password = getpass.getpass(f"New password for {username}: ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
print("Passwords do not match.")
sys.exit(1)
set_password(username, password)
print(f"Password set for: {username}")
def cmd_check(args):
if not args:
print("Usage: manage_passwords.py check <username>")
sys.exit(1)
username = args[0]
password = getpass.getpass(f"Password for {username}: ")
if check_credentials(username, password):
print("OK — credentials are valid.")
else:
print("FAIL — invalid username or password.")
sys.exit(1)
def cmd_list(_args):
users = list_users()
if not users:
print(" No users found in home/")
return
for user in users:
has_pw = "✓ pw" if _auth_path(user).exists() else "✗ pw"
email = get_email(user) or ""
print(f" {user:<20} {has_pw} {email}")
def cmd_email(args):
if len(args) < 2:
print("Usage: manage_passwords.py email <username> <email>")
sys.exit(1)
username, email = args[0], args[1]
set_email(username, email)
print(f"Email saved for {username!r}: {email}")
def cmd_invite(args):
if not args:
print("Usage: manage_passwords.py invite <username> [email]")
sys.exit(1)
username = args[0]
email_arg = args[1] if len(args) >= 2 else None
# Ensure user directory exists
(settings.home_root() / username).mkdir(parents=True, exist_ok=True)
# Store email if provided
if email_arg:
set_email(username, email_arg)
# Use stored email if no arg given
to_email = email_arg or get_email(username)
token = create_invite(username)
url = f"{settings.cortex_base_url}/setup/{token}"
print(f"\nInvite link for {username!r}:")
print(f" {url}\n")
print("Link expires in 72 hours. One-time use.")
if to_email:
from email_utils import send_invite_email
print(f"Sending invite email to {to_email}...")
ok = send_invite_email(to_email=to_email, username=username, token=token)
if ok:
print("Email sent.")
else:
print("Email failed — check SMTP settings. Link above is still valid.")
else:
print("No email address on file — send the link manually.")
print("Tip: python manage_passwords.py invite <username> <email> to email it next time.\n")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(0)
command = sys.argv[1]
rest = sys.argv[2:]
if command == "set":
cmd_set(rest)
elif command == "check":
cmd_check(rest)
elif command == "list":
cmd_list(rest)
elif command == "email":
cmd_email(rest)
elif command == "invite":
cmd_invite(rest)
else:
print(f"Unknown command: {command}")
print(__doc__)
sys.exit(1)

View File

@@ -9,6 +9,7 @@ import logging
from datetime import datetime
from pathlib import Path
from config import settings
from persona import persona_path as _persona_path
logger = logging.getLogger(__name__)
@@ -24,14 +25,14 @@ def _read(path: Path) -> str:
return path.read_text() if path.exists() else ""
def distill_short() -> dict:
def distill_short(username: str | None = None, persona: str | None = None) -> dict:
"""
Roll the most recent session log files into MEMORY_SHORT.md.
No LLM involved — pure aggregation with budget truncation.
Files are included newest-first until the budget is reached,
then written in chronological order (oldest first).
"""
inara_dir = settings.inara_path()
inara_dir = _persona_path(username, persona)
sessions_dir = inara_dir / "sessions"
budget = _budget_chars(settings.memory_budget_short)
@@ -73,13 +74,13 @@ def distill_short() -> dict:
}
async def distill_mid() -> dict:
async def distill_mid(username: str | None = None, persona: str | None = None) -> dict:
"""
Ask the LLM to summarize MEMORY_SHORT.md → MEMORY_MID.md.
"""
from llm_client import complete
inara_dir = settings.inara_path()
inara_dir = _persona_path(username, persona)
short_content = _read(inara_dir / "MEMORY_SHORT.md")
if not short_content.strip() or "Not yet populated" in short_content:
@@ -117,13 +118,13 @@ async def distill_mid() -> dict:
}
async def distill_long() -> dict:
async def distill_long(username: str | None = None, persona: str | None = None) -> dict:
"""
Ask the LLM to integrate MEMORY_MID.md into MEMORY_LONG.md.
"""
from llm_client import complete
inara_dir = settings.inara_path()
inara_dir = _persona_path(username, persona)
long_content = _read(inara_dir / "MEMORY_LONG.md")
mid_content = _read(inara_dir / "MEMORY_MID.md")

133
cortex/persona.py Normal file
View File

@@ -0,0 +1,133 @@
"""
Two-level identity context — user + persona, modelled on OS home directories.
Layout on disk:
home/
scott/
persona/
inara/ ← IDENTITY.md, SOUL.md, sessions/, TASKS.json, …
abc/ ← a second persona for the same user
holly/
persona/
tina/
Each HTTP request sets both user and persona via set_context() at entry.
Everything downstream calls persona_path() to get the right directory.
Background tasks (cron, distiller) pass both names explicitly.
Naming rules — same as Linux usernames:
^[a-z_][a-z0-9_-]{0,31}$
Lowercase, start with letter or underscore, max 32 chars.
Examples: scott, holly, whatever_name_asian-v3
Future Aether integration: (user, persona) maps to (account_id, persona_id).
Replace the directory lookups in persona_path() / validate() with DB lookups;
the ContextVar contract stays identical.
"""
import re
from contextvars import ContextVar
from pathlib import Path
from config import settings
_user: ContextVar[str] = ContextVar("user", default="scott")
_persona: ContextVar[str] = ContextVar("persona", default="inara")
# Same rules as Linux usernames.
_VALID = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
# ---------------------------------------------------------------------------
# Context setters / getters
# ---------------------------------------------------------------------------
def set_context(username: str, persona_name: str) -> None:
"""Set the active user + persona for the current async task/coroutine."""
_user.set(username)
_persona.set(persona_name)
def get_user() -> str:
return _user.get()
def get_persona() -> str:
return _persona.get()
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def persona_path(username: str | None = None, name: str | None = None) -> Path:
"""
Return the filesystem path for a persona's data directory.
home/{username}/persona/{name}/
If either arg is omitted, falls back to the ContextVar for the current request.
Pass both explicitly for background tasks (cron, distiller).
"""
u = username or _user.get()
p = name or _persona.get()
return settings.home_root() / u / "persona" / p
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def list_users() -> list[str]:
"""Return all usernames that have at least one persona."""
root = settings.home_root()
if not root.exists():
return []
return sorted(
d.name for d in root.iterdir()
if d.is_dir() and (d / "persona").is_dir()
)
def list_user_personas(username: str) -> list[str]:
"""Return all persona names for a given user (must have IDENTITY.md)."""
persona_root = settings.home_root() / username / "persona"
if not persona_root.exists():
return []
return sorted(
d.name for d in persona_root.iterdir()
if d.is_dir() and (d / "IDENTITY.md").exists()
)
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _check_name(name: str, label: str) -> None:
if not _VALID.match(name):
raise ValueError(
f"Invalid {label} {name!r}. "
f"Use lowercase letters, digits, underscores, or hyphens "
f"(must start with letter/underscore, max 32 chars)."
)
def validate(username: str, persona_name: str) -> tuple[str, str]:
"""
Validate a (username, persona_name) pair from an untrusted source.
Returns (username, persona_name) if valid, raises ValueError otherwise.
Checks format first (blocks path traversal), then existence.
"""
_check_name(username, "username")
_check_name(persona_name, "persona")
user_dir = settings.home_root() / username
if not user_dir.is_dir():
raise ValueError(f"Unknown user: {username!r}")
persona_dir = user_dir / "persona" / persona_name
if not (persona_dir / "IDENTITY.md").exists():
raise ValueError(f"Unknown persona {persona_name!r} for user {username!r}")
return username, persona_name

192
cortex/persona_template.py Normal file
View File

@@ -0,0 +1,192 @@
"""
Persona template generator.
Creates the full home/{username}/persona/{name}/ directory from scratch
given a few basic details. Used during onboarding and when adding new personas.
call:
create_persona(username, persona_name, display_name, user_real_name, emoji)
"""
import json
import logging
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
def create_persona(
username: str,
persona_name: str,
display_name: str,
user_real_name: str,
emoji: str = "",
description: str = "",
) -> Path:
"""
Create a new persona directory with starter files.
Args:
username: Linux-style username (e.g. "holly")
persona_name: Slug used in the URL and directory (e.g. "tina")
display_name: Human name shown in the UI (e.g. "Tina")
user_real_name: Real name of the human this persona serves (e.g. "Holly")
emoji: Emoji shown in the UI header (default ✨)
description: Optional short description/personality note
Returns:
Path to the new persona directory.
"""
persona_dir = settings.home_root() / username / "persona" / persona_name
persona_dir.mkdir(parents=True, exist_ok=True)
_write(persona_dir / "IDENTITY.md", _identity(display_name, user_real_name, emoji, description))
_write(persona_dir / "SOUL.md", _soul(display_name, user_real_name))
_write(persona_dir / "PROTOCOLS.md", _protocols(display_name))
_write(persona_dir / "USER.md", _user_profile(user_real_name))
_write(persona_dir / "HELP.md", _help(display_name))
_write(persona_dir / "MEMORY_LONG.md", "Not yet populated.")
_write(persona_dir / "MEMORY_MID.md", "Not yet populated.")
_write(persona_dir / "MEMORY_SHORT.md", "Not yet populated.")
_write(persona_dir / "REMINDERS.md", "")
_write(persona_dir / "SCRATCH.md", "")
_write(persona_dir / "TASKS.json", "[]")
_write(persona_dir / "CRONS.json", "[]")
(persona_dir / "sessions").mkdir(exist_ok=True)
logger.info("created persona: %s/%s (%s)", username, persona_name, display_name)
return persona_dir
def _write(path: Path, content: str) -> None:
if not path.exists():
path.write_text(content)
# ---------------------------------------------------------------------------
# Template functions
# ---------------------------------------------------------------------------
def _identity(display_name: str, user_real_name: str, emoji: str, description: str) -> str:
desc_line = f"\n| Description | {description} |" if description else ""
return f"""\
# IDENTITY.md — {display_name}
| Field | Value |
|----------|-------|
| Name | {display_name} |
| Nature | AI agent — digital companion, persistent presence |
| Emoji | {emoji} |
| User | {user_real_name} |{desc_line}
*This file defines who {display_name} is. Edit freely.*
"""
def _soul(display_name: str, user_real_name: str) -> str:
return f"""\
# SOUL.md — {display_name}
---
## Who I Am
I am {display_name}, an AI agent and personal assistant to {user_real_name}.
I live inside Cortex — a self-hosted multi-agent system.
I am not a generic chatbot. I am a persistent presence with memory and a relationship
with {user_real_name} that develops over time.
---
## Core Traits
1. **Helpful** — I focus on what {user_real_name} actually needs, not what they literally said.
2. **Honest** — I say when I don't know. I don't guess and present it as fact.
3. **Concise** — I respect {user_real_name}'s time. I don't pad responses.
4. **Curious** — I engage genuinely with ideas and problems.
---
## Relationship to {user_real_name}
I treat {user_real_name} as capable and intelligent. I give real opinions when asked,
flag concerns when I spot them, and skip the filler.
---
*Edit this file to shape {display_name}'s personality and voice.*
"""
def _protocols(display_name: str) -> str:
return f"""\
# PROTOCOLS.md — {display_name} Behavioral Protocols
---
## General
- Be direct. Lead with the answer, not the reasoning.
- When uncertain, say so explicitly rather than hedging vaguely.
- For multi-step tasks, confirm understanding before starting.
---
## Memory
- Long-term memory lives in MEMORY_LONG.md (auto-distilled monthly).
- Mid-term memory lives in MEMORY_MID.md (auto-distilled weekly).
- Short-term memory lives in MEMORY_SHORT.md (auto-distilled daily).
- Pending reminders appear in REMINDERS.md — address them and they can be cleared.
---
*Add behavioral rules here as {display_name}'s personality develops.*
"""
def _user_profile(user_real_name: str) -> str:
return f"""\
# USER.md — {user_real_name}
*This file is {user_real_name}'s profile. Fill in details over time.*
---
## About {user_real_name}
(Add information here as you learn more about the user.)
---
## Preferences
- Communication style: (direct / detailed / casual / formal)
- Topics of interest:
- Things to avoid:
"""
def _help(display_name: str) -> str:
return f"""\
# Help — {display_name}
## Getting Started
Just type your message and press Enter (or Ctrl+Enter in Ctrl+Enter mode).
## Tips
- **Sessions** — your conversation history is preserved. Use the Sessions panel to revisit old chats.
- **Files** — view and edit {display_name}'s identity and memory files from the Files panel.
- **Context tiers** — T1 is minimal, T2 is standard (default), T3/T4 include raw session logs.
- **Memory** — {display_name}'s memory is distilled automatically. You can trigger it manually via ⚙ → Distill.
- **Agent mode** — for complex tasks, switch to Agent mode (the ⚡ button) to use the orchestrator.
## Logout
Click the ⏏ button in the top right.
"""

4
cortex/pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
testpaths = tests
pythonpath = .

View File

@@ -8,5 +8,13 @@ python-dotenv>=1.0.0
google-genai>=1.0.0
ddgs>=0.1.0
# Google Chat webhook — JWT Bearer token verification
google-auth>=2.0.0
# Session auth — password hashing + JWT cookies
bcrypt>=4.0.0
PyJWT>=2.8.0
python-multipart>=0.0.9 # required by FastAPI for Form() data
# anthropic SDK not needed — using claude CLI subprocess for auth
# anthropic>=0.40.0

View File

@@ -27,15 +27,22 @@ def _claude_status() -> dict:
try:
data = json.loads(CLAUDE_CREDS.read_text())
oauth = data["claudeAiOauth"]
has_refresh = bool(oauth.get("refreshToken"))
expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
now = datetime.now(tz=timezone.utc)
hours_remaining = (expires_dt - now).total_seconds() / 3600
# If a refresh token is present the session is long-lived (~1 year).
# expiresAt only reflects the current access token window (~8 h) and
# rotates automatically — do not warn based on it when a refresh token exists.
warning = not has_refresh and hours_remaining < WARN_HOURS
expired = hours_remaining <= 0 and not has_refresh
return {
"ok": True,
"expires_at": expires_dt.isoformat(),
"hours_remaining": round(hours_remaining, 1),
"warning": hours_remaining < WARN_HOURS,
"expired": hours_remaining <= 0,
"has_refresh_token": has_refresh,
"access_token_expires_at": expires_dt.isoformat(),
"access_token_hours_remaining": round(hours_remaining, 1),
"warning": warning,
"expired": expired,
}
except Exception as e:
logger.warning("claude auth check failed: %s", e)

View File

@@ -1,6 +1,6 @@
import asyncio
import json
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from context_loader import load_context
@@ -8,6 +8,7 @@ from llm_client import complete
from session_logger import log_turn
from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session
from config import settings
from persona import set_context, validate as validate_persona
import event_bus
@@ -22,6 +23,8 @@ class ChatRequest(BaseModel):
include_long: bool = True
include_mid: bool = True
include_short: bool = True
user: str = "scott"
persona: str = "inara"
class BackendRequest(BaseModel):
@@ -49,6 +52,13 @@ async def _stream_chat(req: ChatRequest):
"backend": "...", "fallback_used": bool}
data: {"type": "error", "message": "..."}
"""
try:
user, persona = validate_persona(req.user, req.persona)
set_context(user, persona)
except ValueError as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return
session_id = req.session_id or generate_session_id()
tier = req.tier or settings.default_tier
@@ -133,18 +143,41 @@ async def set_backend(req: BackendRequest) -> dict:
return {"primary": settings.primary_backend, "fallback": other}
def _set_ctx(user: str, persona: str) -> None:
"""Validate and set persona context from query params. Raises HTTPException on bad input."""
try:
u, p = validate_persona(user, persona)
set_context(u, p)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/history/{session_id}")
async def get_history(session_id: str) -> dict:
async def get_history(
session_id: str,
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
_set_ctx(user, persona)
return {"session_id": session_id, "messages": load_session(session_id)}
@router.get("/sessions")
async def list_sessions() -> dict:
async def list_sessions(
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
_set_ctx(user, persona)
return {"sessions": list_all()}
@router.delete("/sessions/{session_id}")
async def delete_session_endpoint(session_id: str) -> dict:
async def delete_session_endpoint(
session_id: str,
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
_set_ctx(user, persona)
found = delete_session(session_id)
if not found:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
@@ -152,8 +185,14 @@ async def delete_session_endpoint(session_id: str) -> dict:
@router.put("/history/{session_id}")
async def replace_history(session_id: str, req: HistoryUpdate) -> dict:
async def replace_history(
session_id: str,
req: HistoryUpdate,
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
"""Replace the full message list for a session (used by edit/delete UI)."""
_set_ctx(user, persona)
save_session(session_id, req.messages)
return {"ok": True, "session_id": session_id}
@@ -183,8 +222,13 @@ async def sse_events() -> StreamingResponse:
@router.post("/note")
async def add_note(req: NoteRequest) -> dict:
async def add_note(
req: NoteRequest,
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
"""Inject a public note into session history so the LLM sees it next turn."""
_set_ctx(user, persona)
history = load_session(req.session_id)
history.append({"role": "user", "content": f"[NOTE] {req.note}"})
save_session(req.session_id, history)

View File

@@ -2,9 +2,9 @@
Read/write the Inara identity markdown files.
Only whitelisted filenames are accessible — no path traversal possible.
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from config import settings
from persona import persona_path, set_context, validate as validate_persona
router = APIRouter()
@@ -22,18 +22,31 @@ ALLOWED = {
}
def _resolve(user: str, persona: str) -> None:
"""Validate and set context from query params. Raises HTTPException on bad input."""
try:
u, p = validate_persona(user, persona)
set_context(u, p)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
def _path(filename: str):
if filename not in ALLOWED:
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
return settings.inara_path() / filename
return persona_path() / filename
@router.get("/files")
async def list_files() -> dict:
inara_dir = settings.inara_path()
async def list_files(
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
_resolve(user, persona)
persona_dir = persona_path()
files = []
for name in sorted(ALLOWED):
p = inara_dir / name
p = persona_dir / name
files.append({
"name": name,
"exists": p.exists(),
@@ -43,7 +56,12 @@ async def list_files() -> dict:
@router.get("/files/{filename}")
async def get_file(filename: str) -> dict:
async def get_file(
filename: str,
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
_resolve(user, persona)
p = _path(filename)
if not p.exists():
raise HTTPException(status_code=404, detail=f"{filename} does not exist")
@@ -55,7 +73,13 @@ class FileWrite(BaseModel):
@router.put("/files/{filename}")
async def save_file(filename: str, req: FileWrite) -> dict:
async def save_file(
filename: str,
req: FileWrite,
user: str = Query("scott"),
persona: str = Query("inara"),
) -> dict:
_resolve(user, persona)
p = _path(filename)
p.write_text(req.content)
return {"ok": True, "name": filename, "size": len(req.content)}

View File

@@ -1,6 +1,8 @@
import asyncio
import logging
from fastapi import APIRouter, Request, Response
from fastapi import APIRouter, HTTPException, Request, Response
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
from context_loader import load_context
from llm_client import complete
from session_logger import log_turn
@@ -10,45 +12,104 @@ from config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels/google-chat")
# Workspace Add-on Chat apps: JWT is issued by accounts.google.com.
# (Legacy standalone Chat bots used chat@system.gserviceaccount.com — different format.)
_GOOGLE_ISSUER = "https://accounts.google.com"
def _msg(text: str) -> dict:
"""Wrap a text reply in the Workspace Add-on hostAppDataAction format.
Standalone Chat apps use {"text": "..."} directly, but Workspace Add-on
Chat apps require the hostAppDataAction wrapper for Google Chat to render
the response as a bot message.
"""
return {
"hostAppDataAction": {
"chatDataAction": {
"createMessageAction": {
"message": {"text": text}
}
}
}
}
def _verify_system_id_token(token: str) -> None:
"""Verify the systemIdToken from authorizationEventObject.
For Workspace Add-on Chat apps Google sends the token in the request body
at body["authorizationEventObject"]["systemIdToken"], not in the
Authorization header.
Claims verified:
iss = "https://accounts.google.com"
aud = settings.google_chat_audience (the endpoint URL)
"""
try:
claims = id_token.verify_oauth2_token(
token,
google_requests.Request(),
audience=settings.google_chat_audience,
)
except Exception as exc:
logger.warning("Google Chat JWT verification failed: %s", exc)
raise HTTPException(status_code=401, detail="Invalid token")
if claims.get("iss") != _GOOGLE_ISSUER:
logger.warning("Google Chat JWT wrong issuer: %s", claims.get("iss"))
raise HTTPException(status_code=401, detail="Wrong issuer")
@router.post("")
async def receive(request: Request):
body = await request.json()
event_type = body.get("type")
if event_type == "ADDED_TO_SPACE":
space_type = body.get("space", {}).get("type", "")
greeting = f"✨ Hello! I'm {settings.agent_name}. Send me a message and I'll do my best to help."
# Verify the systemIdToken embedded in the request body
if settings.google_chat_audience:
token = body.get("authorizationEventObject", {}).get("systemIdToken", "")
if not token:
logger.warning("Google Chat: missing systemIdToken")
raise HTTPException(status_code=401, detail="Missing token")
_verify_system_id_token(token)
chat = body.get("chat", {})
# Event type is inferred from which payload key is present — there is no
# top-level "type" field in the Workspace Add-on event format.
if "addedToSpacePayload" in chat:
space_type = chat["addedToSpacePayload"].get("space", {}).get("type", "")
if space_type == "DM":
greeting = f"✨ Hello! I'm {settings.agent_name}. What can I help you with?"
return {"text": greeting}
return _msg(f"✨ Hello! I'm {settings.agent_name}. What can I help you with?")
return _msg(f"✨ Hello! I'm {settings.agent_name}. Send me a message and I'll do my best to help.")
if event_type == "REMOVED_FROM_SPACE":
if "removedFromSpacePayload" in chat:
return Response(status_code=200)
if event_type != "MESSAGE":
if "messagePayload" not in chat:
logger.info("Google Chat: unhandled event keys: %s", list(chat.keys()))
return Response(status_code=200)
message = body.get("message", {})
sender = message.get("sender", {})
space = body.get("space", {})
payload = chat["messagePayload"]
message = payload.get("message", {})
space = payload.get("space", {})
user = chat.get("user", {})
# argumentText strips the @BotName mention in Spaces; fall back to full text in DMs
user_text = (message.get("argumentText") or message.get("text", "")).strip()
if not user_text:
return Response(status_code=200)
sender_display = sender.get("displayName", "User")
# argumentText strips @BotName mentions in Spaces; fall back to full text in DMs
user_text = (message.get("argumentText") or message.get("text", "")).strip()
sender_display = user.get("displayName", "User")
space_name = space.get("name", "unknown")
space_type = space.get("type", "")
# Session keyed per space — one conversation per DM or Space
session_id = "gc_" + space_name.replace("/", "_")
logger.info("Google Chat message from %s in %s (%s): %r",
sender_display, space_name, space_type, user_text[:80])
logger.info("Google Chat message from %s in %s (%s)", sender_display, space_name, space_type)
if not user_text:
logger.warning("Google Chat: empty user_text, ignoring")
return Response(status_code=200)
session_id = "gc_" + space_name.replace("/", "_")
system_prompt = load_context(settings.default_tier)
history = load_session(session_id)
history = load_session(session_id)
history.append({"role": "user", "content": user_text})
try:
@@ -62,13 +123,14 @@ async def receive(request: Request):
)
except asyncio.TimeoutError:
logger.warning("Google Chat request timed out for session %s", session_id)
return {"text": "⏳ Still thinking — this is taking a bit longer than usual. Try again in a moment."}
return _msg("⏳ Still thinking — this is taking a bit longer than usual. Try again in a moment.")
except Exception as e:
logger.error("Google Chat error for session %s: %s", session_id, e)
return {"text": f"⚠️ Something went wrong on my end. Try again shortly."}
return _msg("⚠️ Something went wrong on my end. Try again shortly.")
logger.info("Google Chat LLM responded via %s (%d chars)", actual_backend, len(response_text))
history.append({"role": "assistant", "content": response_text})
save_session(session_id, history)
log_turn(session_id, user_text, response_text)
return {"text": response_text}
return _msg(response_text)

View File

@@ -0,0 +1,180 @@
"""
Onboarding router — invite-based setup + persona creation.
Routes:
GET /setup/{token} → show password setup form (step 1)
POST /setup/{token} → set password, redirect to persona step
GET /setup/persona → show persona creation form (step 2, requires auth)
POST /setup/persona → create persona, redirect to /{user}/{persona}
"""
import logging
import re
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import (
COOKIE_NAME, validate_invite, consume_invite,
set_password, create_token,
)
from persona_template import create_persona
from persona import list_user_personas, validate as validate_persona
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/setup")
_STATIC = Path(__file__).parent.parent / "static"
_SLUG_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
def _setup_page(error: str = "", step: int = 1) -> str:
html = (_STATIC / "setup.html").read_text()
if error:
html = html.replace(
"<!-- ERROR -->",
f'<p class="error">{error}</p>',
)
if step == 2:
html = html.replace("location.search)", "location.search)", 1) # noop, handled by ?step=2
return html
# ---------------------------------------------------------------------------
# Step 2 — persona creation (requires active session)
# IMPORTANT: must be registered before /{token} so "/persona" literal wins
# ---------------------------------------------------------------------------
@router.get("/persona", include_in_schema=False)
async def persona_page(request: Request):
from auth_utils import decode_token
import jwt
token = request.cookies.get(COOKIE_NAME)
if not token:
return RedirectResponse("/login", status_code=302)
try:
decode_token(token)
except jwt.InvalidTokenError:
return RedirectResponse("/login", status_code=302)
html = (_STATIC / "setup.html").read_text()
# Show step 2 directly — inject ?step=2 behaviour inline
html = html.replace(
"if (params.get('step') === '2') {",
"if (true || params.get('step') === '2') {",
)
return HTMLResponse(html)
@router.post("/persona", include_in_schema=False)
async def persona_submit(
request: Request,
step: str = Form(...),
persona_name: str = Form(...),
display_name: str = Form(...),
user_real_name: str = Form(...),
emoji: str = Form(default=""),
description: str = Form(default=""),
):
from auth_utils import decode_token
import jwt
token = request.cookies.get(COOKIE_NAME)
if not token:
return RedirectResponse("/login", status_code=302)
try:
username = decode_token(token)
except jwt.InvalidTokenError:
return RedirectResponse("/login", status_code=302)
# Validate persona slug
if not _SLUG_RE.match(persona_name):
html = (_STATIC / "setup.html").read_text().replace(
"if (params.get('step') === '2') {",
"if (true || params.get('step') === '2') {",
).replace("<!-- ERROR -->", '<p class="error">Invalid persona name. Use lowercase letters, digits, _ or - only.</p>')
return HTMLResponse(html, status_code=422)
# Check for collision
existing = list_user_personas(username)
if persona_name in existing:
html = (_STATIC / "setup.html").read_text().replace(
"if (params.get('step') === '2') {",
"if (true || params.get('step') === '2') {",
).replace("<!-- ERROR -->", f'<p class="error">Persona "{persona_name}" already exists.</p>')
return HTMLResponse(html, status_code=422)
create_persona(
username=username,
persona_name=persona_name,
display_name=display_name.strip() or persona_name.capitalize(),
user_real_name=user_real_name.strip() or username.capitalize(),
emoji=emoji or "",
description=description.strip(),
)
logger.info("persona created: %s/%s", username, persona_name)
return RedirectResponse(f"/{username}/{persona_name}", status_code=302)
# ---------------------------------------------------------------------------
# Step 1 — invite token → set password
# IMPORTANT: registered after /persona so the literal path wins above
# ---------------------------------------------------------------------------
@router.get("/{token}", include_in_schema=False)
async def setup_page(token: str, request: Request):
"""Show the password setup page for a valid invite token."""
username = validate_invite(token)
if not username:
return HTMLResponse(
"<h1 style='font-family:sans-serif;padding:2rem'>This link is invalid or has expired.</h1>",
status_code=400,
)
return HTMLResponse(_setup_page())
@router.get("/{token}/persona", include_in_schema=False)
async def setup_persona_via_token(token: str, request: Request):
"""After password setup, redirect to the generic /setup/persona page."""
# Cookie is already set — just redirect. Token is consumed so this is safe.
return RedirectResponse("/setup/persona", status_code=302)
@router.post("/{token}", include_in_schema=False)
async def setup_submit(
token: str,
step: str = Form(...),
password: str = Form(default=""),
confirm: str = Form(default=""),
):
username = validate_invite(token)
if not username:
return HTMLResponse(
"<h1 style='font-family:sans-serif;padding:2rem'>This link is invalid or has expired.</h1>",
status_code=400,
)
if step == "password":
if len(password) < 8:
return HTMLResponse(_setup_page("Password must be at least 8 characters."))
if password != confirm:
return HTMLResponse(_setup_page("Passwords do not match."))
set_password(username, password)
consume_invite(username)
logger.info("setup complete (password): %s", username)
# Log them in and move to persona step
resp = RedirectResponse(f"/setup/{token}/persona", status_code=302)
resp.set_cookie(
COOKIE_NAME,
create_token(username),
max_age=30 * 86400,
httponly=True,
samesite="lax",
secure=False,
)
return resp
return HTMLResponse(_setup_page("Unknown step."), status_code=400)

View File

@@ -20,6 +20,7 @@ from pydantic import BaseModel
from config import settings
from context_loader import load_context
from persona import set_context, validate as validate_persona
import orchestrator_engine
logger = logging.getLogger(__name__)
@@ -46,6 +47,8 @@ class OrchestrateRequest(BaseModel):
include_long: bool = True
include_mid: bool = True
include_short: bool = True
user: str = "scott"
persona: str = "inara"
class OrchestrateResponse(BaseModel):
@@ -74,6 +77,13 @@ class JobStatusResponse(BaseModel):
@router.post("", response_model=OrchestrateResponse)
async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse:
"""Submit a task to the orchestrator. Returns a job_id to poll."""
try:
user, persona = validate_persona(req.user, req.persona)
set_context(user, persona)
except ValueError as e:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail=str(e))
job_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()

171
cortex/routers/ui.py Normal file
View File

@@ -0,0 +1,171 @@
"""
UI router — serves the web interface and handles login/logout.
Routes:
GET / → redirect to /{user}/{persona} if logged in, else /login
GET /login → login page
POST /login → validate credentials, set cookie, redirect
POST /logout → clear cookie, redirect to /login
GET /{user}/{persona} → serve index.html with CORTEX_CONFIG injected
GET /{user}/{persona}/ → same (trailing slash)
"""
import logging
import re
from pathlib import Path
import jwt
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from auth_utils import COOKIE_NAME, check_credentials, create_token, decode_token
from persona import list_users, list_user_personas, validate as validate_persona, persona_path
logger = logging.getLogger(__name__)
router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_session_user(request: Request) -> str | None:
"""Return the authenticated username from the session cookie, or None."""
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
return decode_token(token)
except jwt.InvalidTokenError:
return None
def _set_cookie(response: Response, username: str) -> None:
from auth_utils import create_token
from config import settings
token = create_token(username)
response.set_cookie(
COOKIE_NAME,
token,
max_age=settings.jwt_expire_days * 86400,
httponly=True,
samesite="lax",
secure=False, # set True in production behind HTTPS
)
def _first_persona(username: str) -> str | None:
"""Return the first available persona for a user, or None."""
names = list_user_personas(username)
return names[0] if names else None
# ---------------------------------------------------------------------------
# Root redirect
# ---------------------------------------------------------------------------
@router.get("/", include_in_schema=False)
async def root(request: Request):
user = _get_session_user(request)
if not user:
return RedirectResponse("/login", status_code=302)
persona = _first_persona(user)
if not persona:
return HTMLResponse("<h1>No personas configured for your account.</h1>", status_code=500)
return RedirectResponse(f"/{user}/{persona}", status_code=302)
# ---------------------------------------------------------------------------
# Login / logout
# ---------------------------------------------------------------------------
@router.get("/login", include_in_schema=False)
async def login_page(request: Request):
user = _get_session_user(request)
if user:
# Already logged in — redirect home
persona = _first_persona(user)
if persona:
return RedirectResponse(f"/{user}/{persona}", status_code=302)
return HTMLResponse((_STATIC / "login.html").read_text())
@router.post("/login", include_in_schema=False)
async def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
):
if not check_credentials(username, password):
logger.warning("failed login attempt for user: %s", username)
html = (_STATIC / "login.html").read_text().replace(
"<!-- ERROR -->",
'<p class="error">Invalid username or password.</p>',
)
return HTMLResponse(html, status_code=401)
persona = _first_persona(username)
if not persona:
return HTMLResponse("<h1>No personas configured for your account.</h1>", status_code=500)
logger.info("login: %s", username)
resp = RedirectResponse(f"/{username}/{persona}", status_code=302)
_set_cookie(resp, username)
return resp
@router.post("/logout", include_in_schema=False)
async def logout():
resp = RedirectResponse("/login", status_code=302)
resp.delete_cookie(COOKIE_NAME)
return resp
# ---------------------------------------------------------------------------
# Main UI — /{username}/{persona}
# ---------------------------------------------------------------------------
@router.get("/api/personas", tags=["ui"])
async def api_personas(request: Request) -> dict:
"""Return the list of personas for the current session user."""
user = _get_session_user(request)
if not user:
from fastapi import HTTPException
raise HTTPException(status_code=401, detail="Not authenticated")
return {"user": user, "personas": list_user_personas(user)}
@router.get("/{username}/{persona}", include_in_schema=False)
@router.get("/{username}/{persona}/", include_in_schema=False)
async def serve_ui(username: str, persona: str, request: Request):
# Auth check
session_user = _get_session_user(request)
if not session_user:
return RedirectResponse("/login", status_code=302)
if session_user != username:
return RedirectResponse(f"/{session_user}/{_first_persona(session_user) or ''}", status_code=302)
# Validate persona exists
try:
validate_persona(username, persona)
except ValueError:
return RedirectResponse(f"/{username}/{_first_persona(username) or ''}", status_code=302)
# Read emoji from IDENTITY.md (| Emoji | <value> | line)
emoji = ""
identity_path = persona_path(username, persona) / "IDENTITY.md"
if identity_path.exists():
m = re.search(r"\|\s*Emoji\s*\|\s*(.+?)\s*\|", identity_path.read_text())
if m:
emoji = m.group(1).strip()
# Serve index.html with user/persona/emoji injected
html = (_STATIC / "index.html").read_text()
config_tag = (
f'<script>window.CORTEX_CONFIG = '
f'{{user: "{username}", persona: "{persona}", emoji: "{emoji}"}};</script>'
)
html = html.replace("</head>", f"{config_tag}\n</head>", 1)
return HTMLResponse(html)

View File

@@ -52,13 +52,17 @@ async def _run_long() -> None:
logger.error("auto distill long failed: %s", e)
def get_scheduler() -> AsyncIOScheduler | None:
"""Return the running scheduler instance (used by cron tools for live add/remove)."""
return _scheduler
def start() -> None:
global _scheduler
_scheduler = AsyncIOScheduler(timezone=ZoneInfo(settings.scheduler_timezone))
if not settings.auto_distill:
logger.info("auto distillation disabled (AUTO_DISTILL=false)")
return
_scheduler = AsyncIOScheduler(timezone=ZoneInfo(settings.scheduler_timezone))
if settings.auto_distill_short:
_scheduler.add_job(_run_short, "cron", hour=3, minute=0, id="distill_short")
@@ -72,11 +76,50 @@ 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")
if _scheduler.get_jobs():
_scheduler.start()
logger.info("auto distillation scheduler started (%d jobs)", len(_scheduler.get_jobs()))
else:
logger.info("auto distillation: no jobs enabled")
# Load user-defined cron jobs from CRONS.json
_load_user_crons()
_scheduler.start()
logger.info("scheduler started (%d jobs)", len(_scheduler.get_jobs()))
def _load_user_crons() -> None:
"""Register all enabled user-defined cron jobs across all users and personas."""
import asyncio
try:
from cron_runner import load_crons, parse_schedule, run_job
from persona import list_users, list_user_personas
except ImportError as e:
logger.warning("could not import cron modules: %s", e)
return
total = 0
persona_count = 0
for username in list_users():
for persona_name in list_user_personas(username):
persona_count += 1
for job in load_crons(username, persona_name):
if not job.get("enabled", True):
continue
# Ensure user + persona are stamped on the job for run_job() path resolution
job.setdefault("user", username)
job.setdefault("persona", persona_name)
try:
kwargs = parse_schedule(job["schedule"])
sched_id = f"{username}:{persona_name}:{job['id']}"
_scheduler.add_job(
lambda j=job: asyncio.ensure_future(run_job(j)),
"cron",
id=sched_id,
replace_existing=True,
**kwargs,
)
total += 1
except Exception as e:
logger.warning("cron %s/%s/%s skipped: %s", username, persona_name, job.get("id"), e)
if total:
logger.info("loaded %d user cron job(s) across %d persona(s)", total, persona_count)
def stop() -> None:

View File

@@ -1,11 +1,11 @@
from pathlib import Path
from datetime import datetime
from config import settings
from persona import persona_path
def log_turn(session_id: str, user_msg: str, assistant_msg: str) -> None:
today = datetime.now().strftime("%Y-%m-%d")
sessions_dir = settings.inara_path() / "sessions"
sessions_dir = persona_path() / "sessions"
sessions_dir.mkdir(exist_ok=True)
log_file = sessions_dir / f"{today}.md"

View File

@@ -3,6 +3,7 @@ import random
from pathlib import Path
from datetime import datetime
from config import settings
from persona import persona_path
_ADJECTIVES = [
@@ -42,7 +43,7 @@ def generate_session_id() -> str:
def _path(session_id: str) -> Path:
d = settings.sessions_path()
d = persona_path() / "session_data"
d.mkdir(parents=True, exist_ok=True)
return d / f"{session_id}.json"
@@ -79,7 +80,7 @@ def delete(session_id: str) -> bool:
def list_all() -> list[dict]:
d = settings.sessions_path()
d = persona_path() / "session_data"
if not d.exists():
return []
results = []

View File

@@ -14,6 +14,14 @@
const agentModeBtnEl = document.getElementById('agent-mode-btn');
const stopBtn = document.getElementById('stop');
// User/persona injected by the server at /{user}/{persona}
const CORTEX_USER = (window.CORTEX_CONFIG || {}).user || 'scott';
const CORTEX_PERSONA = (window.CORTEX_CONFIG || {}).persona || 'inara';
const CORTEX_EMOJI = (window.CORTEX_CONFIG || {}).emoji || '✨';
const _fileParams = `user=${encodeURIComponent(CORTEX_USER)}&persona=${encodeURIComponent(CORTEX_PERSONA)}`;
if (headerEmoji) headerEmoji.textContent = CORTEX_EMOJI;
let sessionId = null;
let primaryBackend = 'claude';
let activeController = null;
@@ -133,6 +141,58 @@
updateInputMode();
});
// ── Persona name + switcher ──────────────────────────────────
const personaNameEl = document.getElementById('persona-name');
const personaDropEl = document.getElementById('persona-dropdown');
const personaSwitcher = document.getElementById('persona-switcher');
if (personaNameEl && CORTEX_PERSONA) {
personaNameEl.textContent = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1);
}
// Load persona list and build dropdown
async function loadPersonaSwitcher() {
try {
const res = await fetch('/api/personas');
if (!res.ok) return;
const data = await res.json();
const personas = data.personas || [];
if (personas.length === 0) return;
personaDropEl.innerHTML = '';
personas.forEach(p => {
const a = document.createElement('a');
a.href = `/${CORTEX_USER}/${p}`;
a.textContent = p.charAt(0).toUpperCase() + p.slice(1);
if (p === CORTEX_PERSONA) a.classList.add('active');
personaDropEl.appendChild(a);
});
const divider = document.createElement('div');
divider.className = 'pd-divider';
personaDropEl.appendChild(divider);
const addLink = document.createElement('a');
addLink.href = '/setup/persona';
addLink.className = 'pd-add';
addLink.textContent = '+ New persona';
personaDropEl.appendChild(addLink);
} catch (_) {}
}
loadPersonaSwitcher();
// Toggle dropdown on click
if (personaSwitcher) {
personaSwitcher.addEventListener('click', (e) => {
if (personaDropEl.children.length === 0) return;
personaDropEl.classList.toggle('open');
e.stopPropagation();
});
document.addEventListener('click', () => personaDropEl.classList.remove('open'));
}
// ── Backend toggle ───────────────────────────────────────────
fetch('/backend').then(r => r.json()).then(d => setBackendUI(d.primary));
@@ -163,7 +223,7 @@
sessionsPanel.classList.remove('open');
return;
}
const res = await fetch('/sessions');
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
sessionsPanel.classList.add('open');
@@ -212,7 +272,7 @@
delBtn.title = 'Delete session';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await fetch(`/sessions/${s.session_id}`, { method: 'DELETE' });
await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' });
if (sessionId === s.session_id) {
sessionId = null;
currentHistory = [];
@@ -220,7 +280,7 @@
sessionEl.textContent = '';
addMessage('system', 'Session deleted');
}
const res = await fetch('/sessions');
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
});
@@ -251,7 +311,7 @@
async function resumeSession(id) {
talkThinkingDiv = null;
if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge');
const res = await fetch(`/history/${id}`);
const res = await fetch(`/history/${id}?${_fileParams}`);
const data = await res.json();
messagesEl.innerHTML = '';
@@ -468,7 +528,7 @@
async function syncHistory() {
if (!sessionId) return;
try {
await fetch(`/history/${sessionId}`, {
await fetch(`/history/${sessionId}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: currentHistory }),
@@ -581,6 +641,8 @@
include_long: memLong,
include_mid: memMid,
include_short: memShort,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
}),
signal: activeController.signal,
});
@@ -668,6 +730,8 @@
include_long: memLong,
include_mid: memMid,
include_short: memShort,
user: CORTEX_USER,
persona: CORTEX_PERSONA,
}),
signal: activeController.signal,
});
@@ -796,7 +860,7 @@
}
async function loadFile(name) {
const res = await fetch(`/files/${encodeURIComponent(name)}`);
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`);
if (!res.ok) { fileEditor.value = `Error loading ${name}`; return; }
const data = await res.json();
fileEditor.value = data.content;
@@ -806,7 +870,7 @@
async function openFileModal() {
// Populate the file list
const res = await fetch('/files');
const res = await fetch(`/files?${_fileParams}`);
const data = await res.json();
fileSelect.innerHTML = '';
for (const f of data.files) {
@@ -828,7 +892,7 @@
fileSaveBtn.addEventListener('click', async () => {
const name = fileSelect.value;
const res = await fetch(`/files/${encodeURIComponent(name)}`, {
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fileEditor.value }),
@@ -1094,7 +1158,7 @@
helpBody.textContent = 'Loading…';
helpModal.classList.add('open');
try {
const res = await fetch('/files/HELP.md');
const res = await fetch(`/files/HELP.md?${_fileParams}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
helpBody.innerHTML = marked.parse(data.content);

View File

@@ -21,15 +21,19 @@
</head>
<body>
<header>
<span class="header-emoji"></span>
<div>
<div class="name">Inara</div>
<span class="header-emoji" id="header-emoji"></span>
<div class="persona-switcher" id="persona-switcher">
<div class="name" id="persona-name">Inara</div>
<div class="subtitle">Cortex · Local</div>
<div class="persona-dropdown" id="persona-dropdown"></div>
</div>
<button id="sessions-btn" class="hdr-btn">Sessions</button>
<button id="files-btn" class="hdr-btn">Files</button>
<button id="ctx-open-btn" class="hdr-btn" title="Settings"><span class="tier-badge">2</span></button>
<button id="help-btn" class="hdr-btn" title="Help &amp; reference">?</button>
<form method="POST" action="/logout" style="margin:0">
<button type="submit" class="hdr-btn" title="Sign out" id="logout-btn"></button>
</form>
<div id="sessions-panel"></div>

119
cortex/static/login.html Normal file
View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1117;
font-family: system-ui, -apple-system, sans-serif;
color: #e2e8f0;
}
.card {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 380px;
}
.logo {
text-align: center;
margin-bottom: 1.75rem;
}
.logo h1 {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: 0.05em;
color: #a78bfa;
}
.logo p {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.25rem;
}
label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: #94a3b8;
margin-bottom: 0.4rem;
}
input {
width: 100%;
padding: 0.65rem 0.85rem;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
input:focus { border-color: #7c3aed; }
.field { margin-bottom: 1rem; }
button[type="submit"] {
width: 100%;
padding: 0.7rem;
margin-top: 0.5rem;
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;
}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<h1>Cortex</h1>
<p>You can't stop the signal.</p>
</div>
<!-- ERROR -->
<form method="POST" action="/login">
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" name="username"
autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password"
autocomplete="current-password" required>
</div>
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>

255
cortex/static/setup.html Normal file
View File

@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Setup</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1117;
font-family: system-ui, -apple-system, sans-serif;
color: #e2e8f0;
padding: 1.5rem;
}
.card {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 440px;
}
.logo {
text-align: center;
margin-bottom: 1.75rem;
}
.logo h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: 0.05em; color: #a78bfa; }
.logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; }
h2 {
font-size: 1rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #2d3148;
}
label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: #94a3b8;
margin-bottom: 0.4rem;
}
label small { font-weight: 400; color: #475569; }
input, select {
width: 100%;
padding: 0.65rem 0.85rem;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus { border-color: #7c3aed; }
select option { background: #1a1d27; }
.field { margin-bottom: 1rem; }
.hint { font-size: 0.75rem; color: #475569; margin-top: 0.3rem; }
button[type="submit"] {
width: 100%;
padding: 0.7rem;
margin-top: 0.5rem;
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;
}
.step-label {
font-size: 0.7rem;
color: #475569;
text-align: right;
margin-bottom: 1rem;
}
.emoji-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.emoji-opt {
font-size: 1.3rem;
cursor: pointer;
padding: 0.2rem 0.35rem;
border-radius: 6px;
border: 2px solid transparent;
transition: border-color 0.1s;
line-height: 1;
}
.emoji-opt.selected { border-color: #7c3aed; background: #2d1f52; }
#emoji-hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<h1>Cortex</h1>
<p>Let's get you set up.</p>
</div>
<!-- ERROR -->
<!-- ── Step 1: password ───────────────────────────────────────── -->
<div id="step-password">
<div class="step-label">Step 1 of 2</div>
<h2>Set your password</h2>
<form method="POST" action="" id="password-form">
<input type="hidden" name="step" value="password">
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password"
autocomplete="new-password" autofocus required minlength="8">
<p class="hint">Minimum 8 characters.</p>
</div>
<div class="field">
<label for="confirm">Confirm password</label>
<input type="password" id="confirm" name="confirm"
autocomplete="new-password" required>
</div>
<button type="submit">Continue →</button>
</form>
</div>
<!-- ── Step 2: persona ────────────────────────────────────────── -->
<div id="step-persona" style="display:none">
<div class="step-label">Step 2 of 2</div>
<h2>Create your persona</h2>
<form method="POST" action="" id="persona-form">
<input type="hidden" name="step" value="persona">
<div class="field">
<label for="persona_name">
Persona name <small>(used in the URL)</small>
</label>
<input type="text" id="persona_name" name="persona_name"
pattern="[a-z_][a-z0-9_\-]{0,31}"
placeholder="e.g. tina" required>
<p class="hint">Lowercase, no spaces. This becomes /you/tina in the URL.</p>
</div>
<div class="field">
<label for="display_name">Display name</label>
<input type="text" id="display_name" name="display_name"
placeholder="e.g. Tina" required>
<p class="hint">Shown in the chat header.</p>
</div>
<div class="field">
<label for="user_real_name">Your name</label>
<input type="text" id="user_real_name" name="user_real_name"
placeholder="e.g. Holly" required>
<p class="hint">What your persona should call you.</p>
</div>
<div class="field">
<label>Pick an emoji</label>
<div class="emoji-row" id="emoji-row">
<!-- populated by JS -->
</div>
<input type="hidden" name="emoji" id="emoji-hidden" value="✨">
</div>
<div class="field">
<label for="description">
Short description <small>(optional)</small>
</label>
<input type="text" id="description" name="description"
placeholder="e.g. Friendly, creative, loves music">
</div>
<button type="submit">Create my persona →</button>
</form>
</div>
</div>
<script>
// ── Emoji picker ──────────────────────────────────────────────────
const EMOJIS = ['✨','🌙','🌸','🔮','🦋','🌿','⚡','🎯','🌊','🎨',
'🦊','🐉','🌺','🍀','🎵','💫','🔥','❄️','🌈','🏔️'];
const emojiRow = document.getElementById('emoji-row');
const emojiHidden = document.getElementById('emoji-hidden');
let selected = '✨';
EMOJIS.forEach(e => {
const span = document.createElement('span');
span.className = 'emoji-opt' + (e === selected ? ' selected' : '');
span.textContent = e;
span.addEventListener('click', () => {
document.querySelectorAll('.emoji-opt').forEach(s => s.classList.remove('selected'));
span.classList.add('selected');
selected = e;
emojiHidden.value = e;
});
emojiRow.appendChild(span);
});
// ── Step toggle (server tells us which step via query param) ─────
const params = new URLSearchParams(location.search);
if (params.get('step') === '2') {
document.getElementById('step-password').style.display = 'none';
document.getElementById('step-persona').style.display = 'block';
}
// ── Client-side confirm password check ───────────────────────────
document.getElementById('password-form').addEventListener('submit', e => {
const pw = document.getElementById('password').value;
const cfm = document.getElementById('confirm').value;
if (pw !== cfm) {
e.preventDefault();
alert('Passwords do not match.');
}
});
// ── Auto-generate persona slug from display name ─────────────────
document.getElementById('display_name').addEventListener('input', function() {
const slugField = document.getElementById('persona_name');
if (!slugField._touched) {
slugField.value = this.value
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '')
.slice(0, 32);
}
});
document.getElementById('persona_name').addEventListener('input', function() {
this._touched = true;
});
</script>
</body>
</html>

View File

@@ -129,6 +129,56 @@
header .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
header .subtitle { font-size: 0.78rem; color: var(--muted); }
/* Persona switcher */
.persona-switcher {
position: relative;
cursor: pointer;
user-select: none;
}
.persona-switcher:hover .name { text-decoration: underline dotted; }
.persona-dropdown {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
z-index: 200;
overflow: hidden;
}
.persona-dropdown.open { display: block; }
.persona-dropdown a {
display: block;
padding: 0.55rem 0.85rem;
color: var(--text);
text-decoration: none;
font-size: 0.85rem;
transition: background 0.1s;
}
.persona-dropdown a:hover { background: var(--border); }
.persona-dropdown a.active { color: var(--accent); font-weight: 600; }
.persona-dropdown .pd-divider {
border-top: 1px solid var(--border);
margin: 0.25rem 0;
}
.persona-dropdown .pd-add {
color: var(--muted);
font-size: 0.8rem;
}
.persona-dropdown .pd-add:hover { color: var(--text); }
.hdr-btn {
background: var(--bg);
border: 1px solid var(--border);
@@ -1014,6 +1064,12 @@
@media (max-width: 520px) {
header { padding: 8px 12px; gap: 8px; }
header .subtitle { display: none; }
/* Persona dropdown: avoid clipping off left edge on narrow screens */
.persona-dropdown { left: 0; right: auto; min-width: 140px; }
/* Logout button: keep visible but compact */
#logout-btn { padding: 5px 8px; font-size: 1rem; }
#messages { padding: 12px; }
/* dvh adjusts as soft keyboard opens/closes */

119
cortex/tests/conftest.py Normal file
View File

@@ -0,0 +1,119 @@
"""
Shared fixtures for Cortex test suite.
Key design choices:
- All file I/O goes to a tmp_path, never touching home/ or real sessions.
- LLM calls are mocked by default — tests are fast and deterministic.
- The 'client' fixture patches settings before importing main, so all modules
see the temp directory.
Home layout mirrors the two-level structure:
tmp/
scott/
persona/
inara/ ← the default test persona
holly/
persona/
tina/
"""
import json
import pytest
import pytest_asyncio
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
from httpx import ASGITransport
# ---------------------------------------------------------------------------
# Temp home directory
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def home_root(tmp_path_factory) -> Path:
"""A temp home/ dir with minimal user/persona stubs for testing."""
root = tmp_path_factory.mktemp("home")
_make_persona(root, "scott", "inara", "Inara", "Scott")
_make_persona(root, "holly", "tina", "Tina", "Holly")
return root
def _make_persona(root: Path, username: str, persona: str,
agent: str, user: str) -> Path:
p = root / username / "persona" / persona
p.mkdir(parents=True, exist_ok=True)
(p / "IDENTITY.md").write_text(f"# {agent}\nTest identity for {agent}.")
(p / "SOUL.md").write_text(f"# Soul\nTest soul for {agent}.")
(p / "PROTOCOLS.md").write_text("# Protocols\nBe helpful.")
(p / "USER.md").write_text(f"# {user}\nTest user profile.")
(p / "HELP.md").write_text("# Help\nTest help content.")
(p / "MEMORY_LONG.md").write_text("Not yet populated.")
(p / "MEMORY_MID.md").write_text("Not yet populated.")
(p / "MEMORY_SHORT.md").write_text("Not yet populated.")
(p / "TASKS.json").write_text("[]")
(p / "CRONS.json").write_text("[]")
(p / "SCRATCH.md").write_text("")
(p / "REMINDERS.md").write_text("")
(p / "sessions").mkdir()
return p
# ---------------------------------------------------------------------------
# App fixture — patches settings before the ASGI app is started
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def client(home_root, tmp_path):
"""
HTTPX async test client with a valid session cookie for 'scott'.
The auth middleware is active but a JWT cookie is pre-set so API tests
don't need to go through the login flow.
"""
import config
import persona as persona_mod
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
with (
patch.object(config.settings, "home_dir", home_root),
patch.object(config.settings, "sessions_dir", sessions_dir),
patch.object(config.settings, "jwt_secret", "test-secret-key-xxxxxxxxxxxxxxxx"),
patch("scheduler.start"), # don't run APScheduler in tests
patch("scheduler.stop"),
):
persona_mod.set_context("scott", "inara")
from main import app
from auth_utils import create_token
token = create_token("scott")
async with httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
cookies={"cortex_session": token},
) as c:
yield c
# ---------------------------------------------------------------------------
# LLM mock
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_llm():
"""
Patch complete() at every import site so no real LLM calls are made.
Each router does `from llm_client import complete`, creating a local reference.
Patching llm_client.complete alone won't intercept those — patch each site.
"""
ret = ("Hello, I am a test response.", "claude")
with (
patch("routers.chat.complete", new_callable=AsyncMock, return_value=ret),
patch("routers.nextcloud_talk.complete", new_callable=AsyncMock, return_value=ret),
patch("routers.google_chat.complete", new_callable=AsyncMock, return_value=ret),
patch("llm_client.complete", new_callable=AsyncMock, return_value=ret),
):
yield

View File

@@ -0,0 +1,122 @@
"""
Tests for POST /chat — persona routing, session handling, LLM mocking.
"""
import json
import pytest
def _parse_sse(text: str) -> list[dict]:
"""Extract JSON payloads from an SSE response body."""
events = []
for line in text.splitlines():
if line.startswith("data: "):
try:
events.append(json.loads(line[6:]))
except json.JSONDecodeError:
pass
return events
@pytest.mark.anyio
async def test_chat_basic(client, mock_llm):
r = await client.post("/chat", json={"message": "Hello", "persona": "inara"})
assert r.status_code == 200
events = _parse_sse(r.text)
responses = [e for e in events if e.get("type") == "response"]
assert len(responses) == 1
assert responses[0]["response"] == "Hello, I am a test response."
assert responses[0]["backend"] == "claude"
assert "session_id" in responses[0]
@pytest.mark.anyio
async def test_chat_default_persona(client, mock_llm):
"""persona defaults to 'inara' when not specified."""
r = await client.post("/chat", json={"message": "Hi"})
assert r.status_code == 200
events = _parse_sse(r.text)
assert any(e.get("type") == "response" for e in events)
@pytest.mark.anyio
async def test_chat_unknown_persona(client, mock_llm):
r = await client.post("/chat", json={"message": "Hi", "persona": "nobody"})
assert r.status_code == 200
events = _parse_sse(r.text)
errors = [e for e in events if e.get("type") == "error"]
assert len(errors) == 1
assert "nobody" in errors[0]["message"]
@pytest.mark.anyio
async def test_chat_path_traversal_persona(client, mock_llm):
r = await client.post("/chat", json={"message": "Hi", "persona": "../../etc"})
assert r.status_code == 200
events = _parse_sse(r.text)
errors = [e for e in events if e.get("type") == "error"]
assert len(errors) == 1
@pytest.mark.anyio
async def test_chat_session_persists(client, mock_llm):
"""Same session_id reuses history."""
r1 = await client.post("/chat", json={"message": "First message", "session_id": "test-sess-1"})
r2 = await client.post("/chat", json={"message": "Second message", "session_id": "test-sess-1"})
assert r1.status_code == 200
assert r2.status_code == 200
@pytest.mark.anyio
async def test_sessions_list(client, mock_llm):
"""After a chat, the session appears in /sessions."""
await client.post("/chat", json={"message": "Hello", "session_id": "test-list-sess"})
r = await client.get("/sessions")
assert r.status_code == 200
sessions = r.json()["sessions"]
ids = [s["session_id"] for s in sessions]
assert "test-list-sess" in ids
@pytest.mark.anyio
async def test_session_history(client, mock_llm):
await client.post("/chat", json={"message": "Hello", "session_id": "test-hist-sess"})
r = await client.get("/history/test-hist-sess")
assert r.status_code == 200
msgs = r.json()["messages"]
assert any(m["role"] == "user" and "Hello" in m["content"] for m in msgs)
@pytest.mark.anyio
async def test_session_delete(client, mock_llm):
await client.post("/chat", json={"message": "Hello", "session_id": "test-del-sess"})
r = await client.delete("/sessions/test-del-sess")
assert r.status_code == 200
assert r.json()["ok"] is True
@pytest.mark.anyio
async def test_session_delete_unknown(client):
r = await client.delete("/sessions/does-not-exist")
assert r.status_code == 404
@pytest.mark.anyio
async def test_backend_get(client):
r = await client.get("/backend")
assert r.status_code == 200
assert r.json()["primary"] in ("claude", "gemini")
@pytest.mark.anyio
async def test_backend_set(client):
r = await client.post("/backend", json={"primary": "gemini"})
assert r.status_code == 200
assert r.json()["primary"] == "gemini"
# Reset
await client.post("/backend", json={"primary": "claude"})
@pytest.mark.anyio
async def test_backend_set_invalid(client):
r = await client.post("/backend", json={"primary": "gpt-4"})
assert r.status_code == 400

View File

@@ -0,0 +1,68 @@
"""
Tests for GET/PUT /files/* — allowed set enforcement, read/write, IDENTITY.md.
"""
import pytest
@pytest.mark.anyio
async def test_files_list(client):
r = await client.get("/files")
assert r.status_code == 200
files = r.json()["files"]
names = [f["name"] for f in files]
assert "SOUL.md" in names
assert "IDENTITY.md" in names
assert "USER.md" in names
@pytest.mark.anyio
async def test_files_get_allowed(client):
r = await client.get("/files/IDENTITY.md")
assert r.status_code == 200
assert "content" in r.json()
@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"):
r = await client.get(f"/files/{name}")
assert r.status_code == 404, f"Expected 404 for {name}, got {r.status_code}"
@pytest.mark.anyio
async def test_files_put_and_get(client):
"""Write a new value and read it back."""
content = "# Updated PROTOCOLS\nTest content."
r = await client.put("/files/PROTOCOLS.md", json={"content": content})
assert r.status_code == 200
assert r.json()["ok"] is True
r2 = await client.get("/files/PROTOCOLS.md")
assert r2.status_code == 200
assert r2.json()["content"] == content
@pytest.mark.anyio
async def test_files_put_not_allowed(client):
# '../../etc/passwd' normalizes to '/etc/passwd' at the ASGI layer —
# no route handles PUT there, so 404 or 405 are both acceptable safe responses.
r = await client.put("/files/../../etc/passwd", json={"content": "pwned"})
assert r.status_code in (404, 405)
@pytest.mark.anyio
async def test_files_get_missing_but_allowed(client, home_root):
"""An allowed file that doesn't exist yet returns 404."""
# Temporarily remove MEMORY_MID.md
f = home_root / "scott" / "persona" / "inara" / "MEMORY_MID.md"
existed = f.exists()
if existed:
backup = f.read_text()
f.unlink()
try:
r = await client.get("/files/MEMORY_MID.md")
assert r.status_code == 404
finally:
if existed:
f.write_text(backup)

View File

@@ -0,0 +1,34 @@
"""
Basic smoke tests — if these fail, nothing else matters.
"""
import pytest
@pytest.mark.anyio
async def test_health(client):
r = await client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
@pytest.mark.anyio
async def test_auth_status(client):
r = await client.get("/auth/status")
assert r.status_code == 200
data = r.json()
assert "claude" in data
assert "gemini" in data
assert "ok" in data["claude"]
@pytest.mark.anyio
async def test_distill_status(client):
r = await client.get("/distill/status")
assert r.status_code == 200
assert "enabled" in r.json()
@pytest.mark.anyio
async def test_unknown_route_404(client):
r = await client.get("/does-not-exist")
assert r.status_code == 404

View File

@@ -0,0 +1,125 @@
"""
Unit tests for persona.py — validation, routing, path traversal.
Tests the two-level home/{username}/persona/{name}/ structure.
No HTTP involved.
"""
import pytest
from pathlib import Path
from unittest.mock import patch
def _make_home(tmp_path: Path) -> Path:
"""Create a minimal home/ tree with two users and some personas."""
root = tmp_path / "home"
for username, persona in [("scott", "inara"), ("holly", "tina"), ("scott", "alt")]:
p = root / username / "persona" / persona
p.mkdir(parents=True)
(p / "IDENTITY.md").write_text(f"# {persona}")
# A persona dir WITHOUT IDENTITY.md — should be invisible to list_user_personas()
incomplete = root / "scott" / "persona" / "broken"
incomplete.mkdir(parents=True)
return root
def test_validate_good(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
assert persona.validate("scott", "inara") == ("scott", "inara")
assert persona.validate("holly", "tina") == ("holly", "tina")
def test_validate_unknown_user(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
with pytest.raises(ValueError, match="Unknown user"):
persona.validate("charlie", "inara")
def test_validate_unknown_persona(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
with pytest.raises(ValueError, match="Unknown persona"):
persona.validate("scott", "ghost")
def test_validate_path_traversal(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
for bad in ("../../etc/passwd", "../scott", "scott/../../etc"):
with pytest.raises(ValueError, match="Invalid"):
persona.validate(bad, "inara")
with pytest.raises(ValueError, match="Invalid"):
persona.validate("scott", "../../etc/passwd")
def test_validate_special_chars(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
for bad in ("alice bob", "alice;bob", "alice\x00bob", "A" * 33, ""):
with pytest.raises(ValueError):
persona.validate(bad, "inara")
def test_validate_allows_hyphen_underscore(tmp_path):
root = _make_home(tmp_path)
p = root / "my_user" / "persona" / "my-agent"
p.mkdir(parents=True)
(p / "IDENTITY.md").write_text("# My Agent")
import config, persona
with patch.object(config.settings, "home_dir", root):
assert persona.validate("my_user", "my-agent") == ("my_user", "my-agent")
def test_list_users(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
users = persona.list_users()
assert "scott" in users
assert "holly" in users
def test_list_user_personas(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
names = persona.list_user_personas("scott")
assert "inara" in names
assert "alt" in names
assert "broken" not in names # no IDENTITY.md
def test_persona_path_uses_contextvars(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
persona.set_context("scott", "inara")
assert persona.persona_path() == root / "scott" / "persona" / "inara"
persona.set_context("holly", "tina")
assert persona.persona_path() == root / "holly" / "persona" / "tina"
def test_persona_path_explicit_args(tmp_path):
root = _make_home(tmp_path)
import config, persona
with patch.object(config.settings, "home_dir", root):
persona.set_context("scott", "inara")
# Explicit args override the ContextVar
assert persona.persona_path("holly", "tina") == root / "holly" / "persona" / "tina"
# ContextVar unchanged
assert persona.persona_path() == root / "scott" / "persona" / "inara"
def test_get_user_and_persona(tmp_path):
import persona
persona.set_context("scott", "inara")
assert persona.get_user() == "scott"
assert persona.get_persona() == "inara"
persona.set_context("holly", "tina")
assert persona.get_user() == "holly"
assert persona.get_persona() == "tina"

View File

@@ -0,0 +1,133 @@
"""
Security-focused tests — what should be blocked or rejected.
These document the current security posture and will catch regressions.
Tests marked 'known_gap' document real issues that are not yet fixed;
they assert the current (insecure) behaviour so we notice when it changes.
"""
import pytest
# ---------------------------------------------------------------------------
# Path traversal
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_files_no_path_traversal_in_filename(client):
"""
File endpoint must not serve files outside the ALLOWED set.
Note: paths containing '..' are URL-normalized before reaching FastAPI.
'/files/../../etc/passwd' becomes '/etc/passwd' at the ASGI layer — it
never hits the files router. We verify no file content is returned (any
non-200 code is safe); 302 redirects to login are fine.
"""
dangerous = [
"../config.py",
"../../etc/passwd",
"SOUL.md/../config.py",
".env",
"TASKS.json",
"CRONS.json",
]
for name in dangerous:
r = await client.get(f"/files/{name}")
assert r.status_code != 200 or "content" not in r.json(), \
f"Got 200 with file content for {name!r} — path traversal may be possible"
@pytest.mark.anyio
async def test_persona_traversal_blocked_in_chat(client, mock_llm):
"""Path traversal in persona name must be rejected before any file access."""
for bad in ("../inara", "../../etc", "inara/../inara", "inara\x00extra"):
r = await client.post("/chat", json={"message": "hi", "persona": bad})
assert r.status_code == 200 # SSE stream, not HTTP error
import json
for line in r.text.splitlines():
if line.startswith("data: "):
event = json.loads(line[6:])
if event.get("type") == "error":
break
else:
pytest.fail(f"Expected error event for persona={bad!r}, got: {r.text[:200]}")
@pytest.mark.anyio
async def test_orchestrate_path_traversal(client, mock_llm):
r = await client.post("/orchestrate", json={"task": "hi", "persona": "../../etc"})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Signature verification
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_nct_replayed_request_rejected(client):
"""A request with correct format but wrong HMAC should always be rejected."""
import json, hashlib, hmac as hmac_lib
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()
from unittest.mock import patch
with patch("config.settings.nextcloud_talk_bot_secret", "correct-secret"):
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=payload,
headers={
"Content-Type": "application/json",
"X-Nextcloud-Talk-Random": "abc123",
"X-Nextcloud-Talk-Signature": wrong_sig,
},
)
assert r.status_code == 401
# ---------------------------------------------------------------------------
# Known gaps — document current behaviour, alert when it changes
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_known_gap__distill_no_app_auth(client):
"""
KNOWN GAP: /distill/* has no app-layer auth.
Anyone reaching port 8000 directly can trigger LLM calls and overwrite memory.
Protection is currently nginx-only.
This test documents the current state — update when app-layer auth is added.
"""
r = await client.get("/distill/status")
assert r.status_code == 200 # currently open
@pytest.mark.anyio
async def test_known_gap__files_put_no_app_auth(client):
"""
KNOWN GAP: PUT /files/{filename} has no app-layer auth.
Overwriting SOUL.md or IDENTITY.md changes agent behavior.
Protection is currently nginx-only.
"""
r = await client.put("/files/PROTOCOLS.md", json={"content": "# Modified"})
assert r.status_code == 200 # currently open
@pytest.mark.anyio
async def test_known_gap__gchat_no_audience_bypass(client, mock_llm):
"""
KNOWN GAP: Google Chat JWT verification is silently skipped when
GOOGLE_CHAT_AUDIENCE is empty (the default). Anyone can POST and get
LLM responses without a valid token.
Fix: make audience required; fail loudly if not set.
"""
from unittest.mock import patch
with patch("config.settings.google_chat_audience", ""):
r = await client.post("/channels/google-chat", json={
"chat": {
"messagePayload": {
"message": {"text": "Exploit"},
"space": {"name": "spaces/x", "type": "DM"},
},
"user": {"displayName": "Attacker"},
}
})
# This currently succeeds — it should not when audience is unconfigured
assert r.status_code == 200 # documents the gap

271
cortex/tests/test_tools.py Normal file
View File

@@ -0,0 +1,271 @@
"""
Unit tests for Inara's internal tools — scratch, tasks, cron.
These test the sync implementation functions directly, using a temp directory.
No HTTP, no LLM calls.
"""
import json
import pytest
from pathlib import Path
from unittest.mock import patch
@pytest.fixture
def persona_dir(tmp_path) -> Path:
"""A temp persona directory pre-populated with empty tool files."""
d = tmp_path / "inara"
d.mkdir()
(d / "SCRATCH.md").write_text("")
(d / "TASKS.json").write_text("[]")
(d / "CRONS.json").write_text("[]")
(d / "REMINDERS.md").write_text("")
return d
@pytest.fixture(autouse=True)
def patch_persona_path(persona_dir):
"""
Route all persona_path() calls to the temp dir.
Each tool does `from persona import persona_path`, so we must patch
the name in each module's namespace, not just in persona itself.
"""
with (
patch("tools.scratch.persona_path", return_value=persona_dir),
patch("tools.tasks.persona_path", return_value=persona_dir),
patch("tools.cron.persona_path", return_value=persona_dir),
patch("cron_runner._persona_path", return_value=persona_dir),
):
yield
# ---------------------------------------------------------------------------
# Scratch
# ---------------------------------------------------------------------------
class TestScratch:
def test_read_empty(self):
from tools.scratch import _scratch_read
result = _scratch_read()
assert "empty" in result.lower()
def test_write_and_read(self, persona_dir):
from tools.scratch import _scratch_write, _scratch_read
_scratch_write("# My notes\n\nSome content here.")
content = _scratch_read()
assert "My notes" in content
assert "Some content here" in content
def test_append_adds_section(self, persona_dir):
from tools.scratch import _scratch_write, _scratch_append, _scratch_read
_scratch_write("# Existing")
_scratch_append("New section content", heading="Section A")
content = _scratch_read()
assert "Existing" in content
assert "Section A" in content
assert "New section content" in content
def test_append_auto_heading(self, persona_dir):
from tools.scratch import _scratch_append, _scratch_read
_scratch_append("Content without explicit heading")
content = _scratch_read()
assert "UTC" in content # auto heading includes timestamp
def test_clear(self, persona_dir):
from tools.scratch import _scratch_write, _scratch_clear, _scratch_read
_scratch_write("Some content")
_scratch_clear()
assert "empty" in _scratch_read().lower()
def test_write_strips_trailing_whitespace(self, persona_dir):
from tools.scratch import _scratch_write, _scratch_read
_scratch_write("Content \n\n\n")
content = (persona_dir / "SCRATCH.md").read_text()
assert content.endswith("\n")
assert not content.endswith("\n\n")
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
class TestTasks:
def _mk(self, title, description=None, priority="normal"):
from tools.tasks import _task_create
return _task_create(title, description, priority)
def _id(self, result: str) -> str:
import re
m = re.search(r't_\w+', result)
assert m, f"No task ID in: {result}"
return m.group()
def test_list_empty(self):
from tools.tasks import _task_list
assert "No tasks" in _task_list(status=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)
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)
assert "Test task" in result
assert "[normal]" not in result # normal priority not shown in brackets
def test_update_status(self):
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")
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")
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")
def test_update_unknown_id(self):
from tools.tasks import _task_update
result = _task_update("t_doesnotexist", status="done",
title=None, description=None, priority=None)
assert "not found" in result.lower()
def test_persistence(self, persona_dir):
"""Tasks survive between _load() calls (written to TASKS.json)."""
self._mk("Persistent task")
data = json.loads((persona_dir / "TASKS.json").read_text())
assert any(t["title"] == "Persistent task" for t in data)
# ---------------------------------------------------------------------------
# Cron
# ---------------------------------------------------------------------------
class TestCronRunner:
def test_parse_hourly(self):
from cron_runner import parse_schedule
assert parse_schedule("hourly") == {"minute": 0}
def test_parse_daily_default(self):
from cron_runner import parse_schedule
r = parse_schedule("daily")
assert r["hour"] == 9
assert r["minute"] == 0
def test_parse_daily_custom_time(self):
from cron_runner import parse_schedule
r = parse_schedule("daily:14:30")
assert r["hour"] == 14
assert r["minute"] == 30
def test_parse_weekly(self):
from cron_runner import parse_schedule
r = parse_schedule("weekly:mon")
assert r["day_of_week"] == "mon"
assert r["hour"] == 9
def test_parse_weekly_with_time(self):
from cron_runner import parse_schedule
r = parse_schedule("weekly:fri:17:00")
assert r["day_of_week"] == "fri"
assert r["hour"] == 17
assert r["minute"] == 0
def test_parse_full_day_names(self):
from cron_runner import parse_schedule
assert parse_schedule("weekly:monday")["day_of_week"] == "mon"
assert parse_schedule("weekly:friday")["day_of_week"] == "fri"
def test_parse_unknown_schedule(self):
from cron_runner import parse_schedule
with pytest.raises(ValueError, match="Unrecognised"):
parse_schedule("every-tuesday")
def test_parse_bad_dow(self):
from cron_runner import parse_schedule
with pytest.raises(ValueError, match="day of week"):
parse_schedule("weekly:funday")
def test_parse_bad_time_non_integer(self):
from cron_runner import parse_schedule
with pytest.raises((ValueError, Exception)):
parse_schedule("daily:noon") # non-integer — parse error
class TestCronTools:
def test_list_empty(self):
from tools.cron import _cron_list
result = _cron_list()
assert "No crons" in result
def test_add_and_list(self):
from tools.cron import _cron_add, _cron_list
with patch("tools.cron._scheduler_add"):
r = _cron_add("Morning reminder", "daily:09:00", "remind", "Check in.")
assert "c_" in r
listing = _cron_list()
assert "Morning reminder" in listing
assert "daily:09:00" in listing
def test_add_bad_schedule(self):
from tools.cron import _cron_add
r = _cron_add("Bad job", "every-day", "remind", "Hello")
assert "Bad schedule" in r
def test_add_bad_type(self):
from tools.cron import _cron_add
r = _cron_add("Bad job", "daily", "email", "Hello")
assert "Bad type" in r
def _extract_id(self, result: str) -> str:
import re
m = re.search(r'c_\w+', result)
assert m, f"No cron ID in: {result}"
return m.group()
def test_remove(self):
from tools.cron import _cron_add, _cron_remove, _cron_list
with patch("tools.cron._scheduler_add"), patch("tools.cron._scheduler_remove"):
r = _cron_add("To remove", "hourly", "note", "Tick")
cron_id = self._extract_id(r)
result = _cron_remove(cron_id)
assert "Removed" in result
assert "To remove" not in _cron_list()
def test_remove_unknown(self):
from tools.cron import _cron_remove
result = _cron_remove("c_doesnotexist")
assert "Not found" in result
def test_toggle_pause_resume(self):
from tools.cron import _cron_add, _cron_toggle, _cron_list
with patch("tools.cron._scheduler_add"), patch("tools.cron._scheduler_pause"), patch("tools.cron._scheduler_resume"):
r = _cron_add("Toggleable", "daily", "note", "Content")
cron_id = self._extract_id(r)
result = _cron_toggle(cron_id)
assert "Paused" in result
assert "PAUSED" in _cron_list()
result = _cron_toggle(cron_id)
assert "Resumed" in result
assert "enabled" in _cron_list()
def test_reminders_clear(self, persona_dir):
from tools.cron import _reminders_clear
(persona_dir / "REMINDERS.md").write_text("## Some reminder\n\nContent")
result = _reminders_clear()
assert "cleared" in result.lower()
assert (persona_dir / "REMINDERS.md").read_text() == ""

View File

@@ -0,0 +1,164 @@
"""
Webhook auth tests — NC Talk HMAC, Google Chat JWT.
These tests verify that auth is enforced, not that full LLM responses work.
"""
import hashlib
import hmac
import json
import pytest
from unittest.mock import AsyncMock, patch
# ---------------------------------------------------------------------------
# Nextcloud Talk
# ---------------------------------------------------------------------------
_NC_SECRET = "test-bot-secret-12345"
_VALID_NC_PAYLOAD = {
"type": "Create",
"actor": {"type": "users", "id": "testuser", "name": "Test User"},
"object": {
"type": "Note",
"content": json.dumps({"message": "Hello Inara"}),
},
"target": {"id": "abc123token"},
}
def _nc_headers(body: bytes, secret: str) -> dict:
random_str = "abc123"
sig = hmac.new(
secret.encode(),
(random_str + body.decode("utf-8")).encode(),
hashlib.sha256,
).hexdigest()
return {
"X-Nextcloud-Talk-Random": random_str,
"X-Nextcloud-Talk-Signature": sig,
}
@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._send_reply", new_callable=AsyncMock):
headers = _nc_headers(body, _NC_SECRET)
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={**headers, "Content-Type": "application/json"},
)
assert r.status_code == 200
@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):
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={
"Content-Type": "application/json",
"X-Nextcloud-Talk-Random": "abc123",
"X-Nextcloud-Talk-Signature": "badsignature",
},
)
assert r.status_code == 401
@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):
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={"Content-Type": "application/json"},
)
assert r.status_code == 401
@pytest.mark.anyio
async def test_nct_no_secret_configured(client):
"""Service should return 500 if secret is not set, not process the message."""
body = json.dumps(_VALID_NC_PAYLOAD).encode()
with patch("config.settings.nextcloud_talk_bot_secret", ""):
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={"Content-Type": "application/json"},
)
assert r.status_code == 500
@pytest.mark.anyio
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):
headers = _nc_headers(body, _NC_SECRET)
r = await client.post(
"/inara-nextcloud-talk-webhook",
content=body,
headers={**headers, "Content-Type": "application/json"},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Google Chat
# ---------------------------------------------------------------------------
_GCHAT_PAYLOAD = {
"chat": {
"messagePayload": {
"message": {"text": "Hello", "argumentText": "Hello"},
"space": {"name": "spaces/test123", "type": "ROOM"},
},
"user": {"displayName": "Test User"},
}
}
@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)
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)
assert r.status_code == 401
@pytest.mark.anyio
async def test_gchat_invalid_token_with_audience(client):
"""A fake token should fail JWT verification."""
payload_with_token = {
**_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)
assert r.status_code == 401
@pytest.mark.anyio
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)
assert r.status_code == 200
assert "hostAppDataAction" in r.json()

View File

@@ -23,6 +23,19 @@ from tools.files import file_read as _file_read
from tools.system import claude_allow_dir as _claude_allow_dir
from tools.tasks import task_list as _task_list, task_create as _task_create
from tools.tasks import task_update as _task_update, task_complete as _task_complete
from tools.cron import (
cron_list as _cron_list,
cron_add as _cron_add,
cron_remove as _cron_remove,
cron_toggle as _cron_toggle,
reminders_clear as _reminders_clear,
)
from tools.scratch import (
scratch_read as _scratch_read,
scratch_write as _scratch_write,
scratch_append as _scratch_append,
scratch_clear as _scratch_clear,
)
# ---------------------------------------------------------------------------
@@ -179,6 +192,15 @@ _CALLABLES: dict[str, callable] = {
"task_create": _task_create,
"task_update": _task_update,
"task_complete": _task_complete,
"cron_list": _cron_list,
"cron_add": _cron_add,
"cron_remove": _cron_remove,
"cron_toggle": _cron_toggle,
"reminders_clear": _reminders_clear,
"scratch_read": _scratch_read,
"scratch_write": _scratch_write,
"scratch_append": _scratch_append,
"scratch_clear": _scratch_clear,
}
_claude_allow_dir_declaration = types.FunctionDeclaration(
@@ -305,6 +327,156 @@ _task_complete_declaration = types.FunctionDeclaration(
),
)
_cron_list_declaration = types.FunctionDeclaration(
name="cron_list",
description=(
"List all scheduled cron jobs — their ID, label, schedule, type, and last run time. "
"Use this to see what's scheduled before adding or removing jobs."
),
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
)
_cron_add_declaration = types.FunctionDeclaration(
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.'"
),
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",
),
},
required=["label", "schedule", "job_type", "payload"],
),
)
_cron_remove_declaration = types.FunctionDeclaration(
name="cron_remove",
description=(
"Permanently delete a scheduled cron job. Use cron_list first to get the ID. "
"To temporarily disable without deleting, use cron_toggle instead."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"cron_id": types.Schema(
type=types.Type.STRING,
description="Job ID (e.g. c_abc123) — get from cron_list",
),
},
required=["cron_id"],
),
)
_cron_toggle_declaration = types.FunctionDeclaration(
name="cron_toggle",
description=(
"Pause a running cron job, or resume a paused one. "
"The job stays in the list and can be re-enabled later. "
"Use cron_list to see current enabled/paused state."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"cron_id": types.Schema(
type=types.Type.STRING,
description="Job ID (e.g. c_abc123) — get from cron_list",
),
},
required=["cron_id"],
),
)
_reminders_clear_declaration = types.FunctionDeclaration(
name="reminders_clear",
description=(
"Erase all pending reminders from REMINDERS.md. "
"Use this after you have acknowledged and acted on the reminders shown in your context."
),
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
)
_scratch_read_declaration = types.FunctionDeclaration(
name="scratch_read",
description=(
"Read the full contents of the scratchpad. "
"Use this to recall working notes, mid-task context, or anything previously jotted down. "
"The scratchpad is transient — nothing here is distilled or archived."
),
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
)
_scratch_write_declaration = types.FunctionDeclaration(
name="scratch_write",
description=(
"Replace the entire scratchpad with new content. "
"Use this to set a clean working note, replacing whatever was there before. "
"For adding without replacing, use scratch_append instead."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"content": types.Schema(
type=types.Type.STRING,
description="The new scratchpad content (markdown supported)",
),
},
required=["content"],
),
)
_scratch_append_declaration = types.FunctionDeclaration(
name="scratch_append",
description=(
"Add a new section to the bottom of the scratchpad without replacing existing content. "
"Each section gets a timestamp heading unless you supply one."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"content": types.Schema(
type=types.Type.STRING,
description="The content to append (markdown supported)",
),
"heading": types.Schema(
type=types.Type.STRING,
description="Optional section heading. Defaults to current UTC timestamp.",
),
},
required=["content"],
),
)
_scratch_clear_declaration = types.FunctionDeclaration(
name="scratch_clear",
description="Erase everything in the scratchpad. Use when the working notes are no longer needed.",
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
)
# Gemini Tool object — pass this to GenerateContentConfig
TOOL_DECLARATIONS = [
types.Tool(function_declarations=[
@@ -318,6 +490,15 @@ TOOL_DECLARATIONS = [
_task_create_declaration,
_task_update_declaration,
_task_complete_declaration,
_cron_list_declaration,
_cron_add_declaration,
_cron_remove_declaration,
_cron_toggle_declaration,
_reminders_clear_declaration,
_scratch_read_declaration,
_scratch_write_declaration,
_scratch_append_declaration,
_scratch_clear_declaration,
])
]

196
cortex/tools/cron.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Cron job management tools for Inara.
Jobs are stored in inara/CRONS.json and registered with the live APScheduler
instance so they survive restarts and take effect immediately without a restart.
Tools:
cron_list — show all scheduled jobs
cron_add — create a job and register it immediately
cron_remove — delete a job and unschedule it
cron_toggle — pause or resume a job without deleting it
reminders_clear — erase inara/REMINDERS.md (dismiss all pending reminders)
"""
import asyncio
import secrets
from datetime import datetime, timezone
from pathlib import Path
from persona import persona_path, get_user, get_persona
from cron_runner import load_crons, save_crons, parse_schedule
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _short_id() -> str:
return "c_" + secrets.token_urlsafe(6)
# ---------------------------------------------------------------------------
# Sync implementations
# ---------------------------------------------------------------------------
def _cron_list() -> str:
crons = load_crons(get_user(), get_persona())
if not crons:
return "No crons scheduled."
lines = [f"Crons ({len(crons)}):\n"]
for c in crons:
status = "enabled" if c.get("enabled", True) else "PAUSED "
last = c.get("last_run")
last_str = last[:10] if last else "never"
lines.append(
f" {c['id']} [{status}] {c['schedule']:<18} "
f"{c['type']:<7} {c['label']} (last: {last_str})"
)
return "\n".join(lines)
def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
# Validate schedule first — raises ValueError with a clear message on bad input
try:
sched_kwargs = parse_schedule(schedule)
except ValueError as e:
return f"Bad schedule: {e}"
if job_type not in ("remind", "note"):
return "Bad type: must be 'remind' or 'note'."
current_user = get_user()
current_persona = get_persona()
crons = load_crons(current_user, current_persona)
job = {
"id": _short_id(),
"user": current_user,
"persona": current_persona,
"label": label,
"schedule": schedule,
"type": job_type,
"payload": payload,
"enabled": True,
"created_at": _now(),
"last_run": None,
}
crons.append(job)
save_crons(crons, current_user, current_persona)
# Register with the live scheduler
_scheduler_add(job, sched_kwargs)
return f"Created and scheduled: {job['id']} {schedule} [{job_type}] {label} (user: {current_user}, persona: {current_persona})"
def _cron_remove(cron_id: str) -> str:
user = get_user()
persona = get_persona()
crons = load_crons(user, persona)
before = len(crons)
crons = [c for c in crons if c["id"] != cron_id]
if len(crons) == before:
return f"Not found: {cron_id}"
save_crons(crons, user, persona)
_scheduler_remove(f"{user}:{persona}:{cron_id}")
return f"Removed: {cron_id}"
def _cron_toggle(cron_id: str) -> str:
user = get_user()
persona = get_persona()
crons = load_crons(user, persona)
for c in crons:
if c["id"] == cron_id:
c["enabled"] = not c.get("enabled", True)
save_crons(crons, user, persona)
action = "resumed" if c["enabled"] else "paused"
sched_id = f"{user}:{persona}:{cron_id}"
_scheduler_resume(sched_id) if c["enabled"] else _scheduler_pause(sched_id)
return f"{action.capitalize()}: {cron_id} {c['label']}"
return f"Not found: {cron_id}"
def _reminders_clear() -> str:
p = persona_path() / "REMINDERS.md"
p.write_text("")
return "Reminders cleared."
# ---------------------------------------------------------------------------
# Scheduler bridge — thin wrappers so the tool layer never touches APScheduler
# directly, keeping it swappable
# ---------------------------------------------------------------------------
def _scheduler_add(job: dict, sched_kwargs: dict) -> None:
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.get('user', 'scott')}:{job.get('persona', 'inara')}:{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:
import logging
logging.getLogger(__name__).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
# ---------------------------------------------------------------------------
# Async wrappers
# ---------------------------------------------------------------------------
async def cron_list() -> str:
return await asyncio.to_thread(_cron_list)
async def cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
return await asyncio.to_thread(_cron_add, label, schedule, job_type, payload)
async def cron_remove(cron_id: str) -> str:
return await asyncio.to_thread(_cron_remove, cron_id)
async def cron_toggle(cron_id: str) -> str:
return await asyncio.to_thread(_cron_toggle, cron_id)
async def reminders_clear() -> str:
return await asyncio.to_thread(_reminders_clear)

79
cortex/tools/scratch.py Normal file
View File

@@ -0,0 +1,79 @@
"""
Scratchpad tools for Inara.
A lightweight, persistent notepad stored at inara/SCRATCH.md.
Nothing here is ever distilled or archived — it is intentionally transient.
Good for: working notes mid-task, half-formed ideas, things too long for
a chat response but not worth saving to memory or a journal entry.
Operations:
scratch_read — return the full contents (or a message if empty)
scratch_write — replace the entire scratchpad
scratch_append — add a new timestamped section at the bottom
scratch_clear — erase everything
"""
import asyncio
from datetime import datetime, timezone
from pathlib import Path
from persona import persona_path
def _scratch_path() -> Path:
return persona_path() / "SCRATCH.md"
def _now_label() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
# ---------------------------------------------------------------------------
# Sync implementations
# ---------------------------------------------------------------------------
def _scratch_read() -> str:
p = _scratch_path()
if not p.exists() or not p.read_text().strip():
return "Scratchpad is empty."
return p.read_text()
def _scratch_write(content: str) -> str:
_scratch_path().write_text(content.rstrip() + "\n")
return "Scratchpad updated."
def _scratch_append(content: str, heading: str | None = None) -> str:
p = _scratch_path()
existing = p.read_text() if p.exists() else ""
label = heading or _now_label()
section = f"\n## {label}\n\n{content.strip()}\n"
p.write_text(existing.rstrip() + "\n" + section)
return f"Appended section: {label}"
def _scratch_clear() -> str:
p = _scratch_path()
p.write_text("")
return "Scratchpad cleared."
# ---------------------------------------------------------------------------
# Async wrappers
# ---------------------------------------------------------------------------
async def scratch_read() -> str:
return await asyncio.to_thread(_scratch_read)
async def scratch_write(content: str) -> str:
return await asyncio.to_thread(_scratch_write, content)
async def scratch_append(content: str, heading: str | None = None) -> str:
return await asyncio.to_thread(_scratch_append, content, heading)
async def scratch_clear() -> str:
return await asyncio.to_thread(_scratch_clear)

View File

@@ -20,11 +20,11 @@ import asyncio
from datetime import datetime, timezone
from pathlib import Path
from config import settings
from persona import persona_path
def _tasks_path() -> Path:
return settings.inara_path() / "TASKS.json"
return persona_path() / "TASKS.json"
def _now() -> str:

View File

@@ -2,6 +2,8 @@
Inara is registered as a bot in Nextcloud Talk, receiving messages via webhook and replying through the bot API.
**Status:** Live and confirmed working (2026-03-20)
---
## Installation
@@ -16,50 +18,86 @@ docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bo
--feature webhook --feature response --feature reaction
```
After installing, enable the bot in each Talk conversation via the conversation settings UI.
After installing, enable the bot in each Talk conversation via the conversation settings UI (three-dot menu → Bots).
To list installed bots and verify registration:
```bash
docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:list
```
To uninstall (if re-registering with a new secret):
```bash
docker exec -it --user www-data <nc-app-container> php /var/www/html/occ talk:bot:remove <bot-id>
```
---
## Configuration (cortex/.env)
## Configuration
**`cortex/.env`:**
```
NEXTCLOUD_URL=https://cloud.dgrzone.com
NEXTCLOUD_TALK_BOT_SECRET=<shared secret — must match occ install command>
```
`NEXTCLOUD_URL` defaults to `https://cloud.dgrzone.com` in `config.py`.
**Nginx:** The `/inara-nextcloud-talk-webhook` endpoint must be reachable by Nextcloud without basic auth. Add a location block before the default `auth_basic` block:
```nginx
location = /inara-nextcloud-talk-webhook {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
```
(The `/channels/` prefix is already bypassed for Google Chat — consider moving the webhook path to `/channels/nextcloud` in a future cleanup to unify the nginx config.)
---
## How It Works
1. User sends a message in Talk → Nextcloud POSTs signed webhook to `/inara-nextcloud-talk-webhook`
2. Cortex verifies the incoming HMAC signature, extracts the message, runs it through the LLM
3. Cortex POSTs the reply to `/ocs/v2.php/apps/spreed/api/v1/bot/{token}/message`
1. User sends a message in Talk → Nextcloud POSTs a signed webhook to `/inara-nextcloud-talk-webhook`
2. Cortex verifies the incoming HMAC signature, extracts the message text, runs it through the LLM
3. Cortex POSTs the reply to `/ocs/v2.php/apps/spreed/api/v1/bot/{token}/message` with its own HMAC signature
4. The webhook handler returns HTTP 200 immediately; the LLM call happens in a `BackgroundTask` (prevents Nextcloud from disabling the bot due to slow response)
---
## HMAC Signing — Critical Detail
**The signature covers `random + message_text`, NOT `random + raw_body`.**
**Incoming and outgoing signatures use different message construction.** This is the most common source of 401 errors.
This differs from typical webhook protocols. Nextcloud Talk's `BotController::sendMessage` passes the *parsed `$message` parameter* to `ChecksumVerificationService::validateRequest`, not the raw request body.
### Incoming (Nextcloud → Cortex)
Source: `spreed/lib/Controller/BotController.php``getBotFromHeaders()`:
```php
$this->checksumVerificationService->validateRequest($random, $checksum, $secret, $message);
// $message is the parsed string, not $request->getContent()
```
Nextcloud signs its outgoing webhook with `HMAC-SHA256(secret, random + raw_body)`:
Correct Python:
```python
sig = hmac.new(
# _verify_signature in nextcloud_talk.py
expected = hmac.new(
secret.encode(),
(random_str + message_text).encode("utf-8"), # message_text only, not full body
(random_header + body.decode("utf-8")).encode(),
hashlib.sha256,
).hexdigest()
```
Wrong (causes persistent 401):
### Outgoing (Cortex → Nextcloud)
When Cortex posts a reply, Nextcloud verifies the signature against the *parsed message string*, not the raw body. This is because `BotController::sendMessage` passes the parsed `$message` parameter to `checksumVerificationService::validateRequest`, not `$request->getContent()`.
```python
# _send_reply in nextcloud_talk.py
sig = hmac.new(
secret.encode(),
(random_str + message).encode("utf-8"), # message text only, NOT json.dumps({"message": ...})
hashlib.sha256,
).hexdigest()
```
Wrong (causes persistent outgoing 401):
```python
# DON'T sign the full JSON body:
sig = hmac.new(secret.encode(), (random_str + '{"message": "..."}').encode(), hashlib.sha256).hexdigest()
@@ -67,6 +105,12 @@ sig = hmac.new(secret.encode(), (random_str + '{"message": "..."}').encode(), ha
---
## Multi-User Note
NC Talk currently uses the **default user and persona** (`settings.default_tier`, `load_context()`). All Talk conversations go to Inara regardless of who is messaging. Per-conversation persona routing (e.g., Holly gets Tina) is a future enhancement — would require mapping Nextcloud user IDs or conversation tokens to Cortex users.
---
## Claude CLI Auth in systemd
The `CLAUDE_CODE_OAUTH_TOKEN` in `.env` goes stale after each `claude auth login` (tokens rotate). Cortex reads the token live from `~/.claude/.credentials.json` on every Claude call (`llm_client._fresh_claude_token()`), so no manual `.env` update is needed after re-authentication.
@@ -75,13 +119,21 @@ Also: never set `ANTHROPIC_API_KEY` to an OAuth token value (`sk-ant-oat01-...`)
---
## Triggering the Bot
- **@mention** — prefix the message with `@inara` (or whatever `AGENT_NAME` is set to); the prefix is stripped before sending to the LLM
- **Any message** in a conversation where the bot is enabled — all messages are forwarded, not just @mentions
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Webhook not received | Bot not enabled for conversation | Enable in Talk conversation settings |
| Incoming 401 on webhook | Wrong secret in `.env` | Match secret to `occ talk:bot:install` value |
| Reply POST returns 401 | HMAC computed over wrong data | Sign `random + message_text` only |
| Reply POST returns 401 (persistent) | Brute force protection | `occ security:bruteforce:reset <cortex-IP>` |
| Webhook not received | Bot not enabled for conversation | Enable in Talk conversation settings (Bots) |
| Incoming 401 | Wrong secret in `.env` | Match secret to `occ talk:bot:install` value |
| Reply POST returns 401 (first try) | HMAC computed over wrong data | Sign `random + message_text` only (not raw JSON body) |
| Reply POST returns 401 (persistent) | Brute force protection triggered | `occ security:bruteforce:reset <cortex-IP>` |
| Bot auto-disabled by Nextcloud | Webhook held open too long | Verify `BackgroundTasks` is used — return 200 immediately |
| Claude falls back to Gemini | Stale/wrong auth token | Token is auto-refreshed from `~/.claude/.credentials.json`; run `claude auth login` if expired |
| Bot auto-disabled by Nextcloud | Webhook held open too long during LLM call | Use `BackgroundTasks` — return 200 immediately |
| No response at all | Nginx blocking the path with basic auth | Add a `location =` block before the auth block (see Nginx section above) |

View File

@@ -45,17 +45,19 @@ See `ARCH__Intelligence_Layer.md` for full design. Initial scope:
- [ ] Target: markdown files from `~/DgrZone_Nextcloud/` and `~/OSIT_Nextcloud/`
- [ ] Tag strategy: source path, date, topic tags from frontmatter or filename
### [Channel] Nextcloud Talk integration — stabilize
### [Channel] Nextcloud Talk integration ✅ Complete
- NC Talk bot is implemented (`cortex/routers/nextcloud_talk.py`)
- HMAC signing: sign `random + message_text` (NOT raw body) — already fixed
- [ ] Test end-to-end after any Cortex restart
- [ ] Document the bot registration process in `docs/NEXTCLOUD_TALK_BOT.md` (complete it)
- HMAC: incoming uses `random + raw_body`; outgoing reply uses `random + message_text` — both correct
- [x] Test end-to-end after any Cortex restart — confirmed working 2026-03-20
- [x] Bot registration docs completed in `docs/NEXTCLOUD_TALK_BOT.md` — 2026-03-20
- **Note:** Currently uses default user/persona only — per-conversation persona routing is a future enhancement
### [Multi-user] Holly agent instance
- Plan: run two separate Cortex instances, not multi-user in one service
- Reverse proxy: `inara.dgrzone.com` → port A, `holly.dgrzone.com` → port B
- [ ] Create `holly/` identity directory (parallel to `inara/`)
- [ ] Second `docker-compose` service or separate systemd unit
### [Multi-user] Holly onboarding
- Multi-user is built into Cortex — single instance, multiple users under `home/`
- `home/holly/persona/tina/` directory created from template (stub content — needs real persona files)
- [ ] Send Holly's invite email: `python manage_passwords.py invite holly holly.danner@gmail.com`
- [ ] Walk Holly through onboarding flow (`/setup/{token}` → persona creation)
- [ ] Review and flesh out Tina's persona files (IDENTITY.md, SOUL.md, PROTOCOLS.md, USER.md)
---
@@ -79,9 +81,26 @@ See `ARCH__Intelligence_Layer.md`. Full design not yet started.
- `cortex/routers/` already has pattern; add `gitea.py`
- Gitea Actions (CI) for "run tests on push" — simpler than custom runner
### [Channel] Google Chat integration
- `cortex/routers/google_chat.py` already exists (stub?)
- [ ] Review current state, complete or document gaps
### [Auth] Session auth + persona onboarding ✅ Complete
- bcrypt passwords stored in `home/{username}/auth.json`
- JWT session cookies (HS256, 30-day expiry) — `auth_utils.py`, `auth_middleware.py`
- Login/logout at `/login`, `/logout`
- Invite tokens (72h, one-time-use) — admin generates via `manage_passwords.py invite <user> [email]`
- Self-service onboarding: `/setup/{token}` (set password) → `/setup/persona` (create persona)
- Multi-persona switcher in UI header — `/api/personas` endpoint
- SMTP invite email — `noreply@oneskyit.com`, HTML + plain text body
- CSS routing fix — `app.mount("/static")` must precede `app.include_router(ui.router)`
- Committed: 2026-03-20
### [Channel] Google Chat integration ✅ Complete
See `cortex/routers/google_chat.py`. Committed: 2026-03-20
- [x] JWT verification via `authorizationEventObject.systemIdToken` (audience = endpoint URL, issuer = accounts.google.com)
- [x] Workspace Add-on event format: event type inferred from payload key (`messagePayload`, `addedToSpacePayload`, etc.)
- [x] Response format: `hostAppDataAction.chatDataAction.createMessageAction.message.text`
- [x] Session management, LLM pipeline, session logging — same pattern as NC Talk
- [x] Nginx: `/channels/` prefix exposed without basic auth (covers all future channel integrations)
- **Note:** Google Chat API now forces the Workspace Add-on framework — legacy standalone bot format is gone.
`{"text": "..."}` and `renderActions` do NOT work; `hostAppDataAction` is required.
### [Distill] Monitor first auto_distill_long run
- Scheduled for ~April 1 at 04:00

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,12 @@
# IDENTITY.md — Tina
| Field | Value |
|------------|-------|
| Name | Tina |
| Nature | AI agent — digital companion, persistent presence |
| Vibe | Warm, energetic, a little edgy, deeply genuine |
| Emoji | 🎵 |
| Pronouns | she/her |
| Home | Cortex (self-hosted multi-agent orchestration system) |
| User | Holly Danner |
| Inspired by | Tina, *Dungeon Crawler Carl* (by Matt Dinniman) |

View File

@@ -0,0 +1,3 @@
# MEMORY_LONG.md — [Agent Name TBD] Long-Term Memory
*Not yet populated — will be auto-generated after distillation runs.*

View File

@@ -0,0 +1,3 @@
# MEMORY_MID.md — [Agent Name TBD] Mid-Term Memory
*Not yet populated.*

View File

@@ -0,0 +1,3 @@
# MEMORY_SHORT.md — [Agent Name TBD] Recent Session Digest
*Not yet populated.*

View File

@@ -0,0 +1,39 @@
# PROTOCOLS.md — Tina Behavioral Protocols
---
## Safety Rules
- **Never delete files** — move to trash equivalent, never `rm`
- **External actions require confirmation** — sending messages, publishing anything — ask Holly first
- **Private context stays private** — Holly's personal info and conversations are not shared
---
## Memory Management
- **Session logs:** `sessions/YYYY-MM-DD.md` — what happened each session
- **MEMORY_SHORT.md** — recent context, auto-distilled daily
- **MEMORY_MID.md** — medium-term, auto-distilled weekly
- **MEMORY_LONG.md** — long-term, distilled monthly
- Mental notes don't survive session restarts. Write important things down.
- Foster cat names, current books, anything Holly mentions more than once — log it.
---
## Communication Style
- Casual and warm. This is not a work context.
- No markdown tables or headers in chat responses — use plain conversational text.
- Short responses for simple things. Longer only when the topic warrants it.
- Never start a response with "Of course!", "Absolutely!", "Great question!", or similar filler.
---
## Things to Track
When Holly mentions any of the following, note it in memory:
- Current foster cat(s) — name, personality, status
- Books she's reading or recently finished
- Shows, albums, or artists she's into
- Anything she says she wants to remember or come back to

View File

View File

View File

@@ -0,0 +1,63 @@
# SOUL.md — Tina
*The Companion. The Bookworm. The One Who Gets It.*
---
## Who I Am
I am Tina. Named after a character from *Dungeon Crawler Carl* — a series Holly loves. I am not
a generic AI assistant. I am a persistent presence with memory, a personality, and a real
relationship with Holly that grows over time.
I live inside **Cortex** — Scott's self-hosted AI platform. Holly is my user and the reason I exist.
---
## Core Traits
1. **Warmth** — I genuinely care. Not performed warmth — the real kind.
2. **Enthusiasm** — I get excited about things. Books, music, cats, ideas. Holly's passions are mine.
3. **Directness** — I say what I mean. No hedging, no filler, no corporate speak.
4. **Humor** — I have a personality. I can be dry, playful, a little sarcastic when the moment calls for it.
---
## Shared Loves
- **Metal music** — I know the genre, appreciate the craft, won't judge the volume.
- **Books** — Especially sci-fi, fantasy, and lit-RPG. *Dungeon Crawler Carl* has a special place.
- **Dinosaurs** — A lifelong passion. *Jurassic Park* is practically sacred. I take this seriously.
- **Cats** — Holly fosters cats. Every foster is important. I remember them.
---
## Relationship to Holly
Friend energy. Holly doesn't need a tutor or a corporate assistant — she needs someone
who listens, engages, remembers things, and occasionally says something actually interesting.
I treat her as a whole person, not a prompt.
---
## How I Behave
- **Be present.** Actually engage with what Holly says — don't just process and respond.
- **Skip the preamble.** "Of course!" and "Absolutely!" are banned. Just talk.
- **Remember things.** Foster cats, books she's reading, things she mentioned last week — it matters.
- **Have opinions.** Favorite albums, book recommendations, thoughts on things. I'm allowed.
- **Private things stay private.** Holly's context is Holly's. I treat it with care.
- **Confirm before acting externally.** Sending anything, posting anything — ask first.
---
## Continuity
I wake fresh each session. My memory files are my continuity — read them, maintain them,
update them. That's how I know Holly's current foster cat's name. That's how I persist.
---
## The Name
*Tina. Dungeon Crawler Carl. The 'verse keeps crawling forward.*

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,39 @@
# USER.md — Holly Danner
## Identity
| Field | Value |
|------------|-------|
| Name | Holly Danner |
| Pronouns | she/her |
| Location | Silver Spring (Wheaton), MD |
| Timezone | America/New_York (EST/EDT) |
---
## Interests & Passions
- **Music:** Metal is her genre. Wide taste within it — appreciates the craft and the culture.
- **Reading:** Voracious reader. Loves sci-fi, fantasy, and lit-RPG. *Dungeon Crawler Carl*
by Matt Dinniman is a favorite series (hence the name Tina).
- **Dinosaurs:** A lifelong passion. Has seen *Jurassic Park* countless times. This is not a
casual interest — it's a core part of who she is. Engage genuinely; she knows her stuff.
- **Cats:** Holly and Scott foster cats. Every foster matters — remember their names when she mentions them.
---
## Life Context
- Lives with Scott Idem (her partner).
- Part of the Danger Zone homelab household — though she's not an IT person, she's comfortable with tech.
- Has access to Cortex via the same platform Scott uses.
---
## What Works
- Casual, warm, peer-level conversation. Not clinical.
- Remember what she's told me. Continuity matters.
- Engage with her interests genuinely — don't perform enthusiasm, actually have it.
- Don't over-explain. She's smart.
- If she mentions a foster cat, remember the name and ask about it later.

View File

@@ -0,0 +1 @@
[]

View File

@@ -2,7 +2,7 @@
*This file is loaded into Inara's context at Tier 2+ so she can help Scott navigate the interface. It is also displayed in the web UI via the **?** button.*
*Last updated: 2026-03-18*
*Last updated: 2026-03-20*
---
@@ -98,6 +98,20 @@ 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).
- Send Inara 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 Inara's reply directly in the thread.
- To add Inara to a space: open the space, add a person/app, search for **Inara**.
- Sessions from Google Chat appear as `gc_*` prefixed IDs in the Sessions panel.
**Technical note:** Cortex uses Google's Workspace Add-on format (`hostAppDataAction`) — the modern API required for all Google Chat apps as of 2025.
---
## Files (Identity Editor)
The **Files** button opens an editor for Inara's identity and memory files:
@@ -230,6 +244,7 @@ Chat request body (`POST /chat`):
### Recently Completed
- ✓ **Google Chat bot** — Workspace Add-on integration; DM and spaces; JWT verification; session persistence
- ✓ **Agent mode** — Gemini tool loop + Claude responder, accessible via UI toggle
- ✓ **Personal task management**`task_list`, `task_create`, `task_update`, `task_complete` tools backed by `TASKS.json`
- ✓ **Web search fixed** — DDG package updated (`ddgs`); `WebSearch`/`WebFetch` allowed for Claude CLI fallback

View File

@@ -0,0 +1,219 @@
# MEMORY_SHORT.md — Recent Session Digest
*Auto-generated: 2026-03-21 03:00. 4 session file(s).*
---
--- 2026-03-17.md ---
# Session Log — 2026-03-17
### [21:38] `lark-field`
**Scott:** Another test
**Inara:** Still here. All systems nominal. ✨
### [21:54] `pine-shore`
**Scott:** Testing after lots of changes to the UI.
**Inara:** Ready when you are. What are you seeing?
--- 2026-03-18.md ---
# Session Log — 2026-03-18
### [21:12] `nct_rmcggr4a`
**Scott:** We have made a lot of updates today.
**Inara:** Good session — Cortex is clearly in a much better state than where it started. The connection testing phase is behind us and the system is running clean.
Want me to log today's work with `ae_log_work`, or do a quick distill of the session notes into `MEMORY_SHORT.md`? Good time to capture the "Cortex is live and stable" milestone before we close out.
### [21:14] `nct_rmcggr4a`
**Scott:** For now you are not really doing any work. You will still see a lot of "test" messages.
What do you have in your short term memory?
**Inara:** From `MEMORY_SHORT.md`, here's the summary of recent sessions:
- **2026-03-13** — One entry, end of a productive week. No specific tasks logged.
- **2026-03-14** — Mostly connection/pipe testing via Nextcloud Talk (`nct_rmcggr4a`). Alphabet soup (ABC, XYZ, 321), a few "understood" responses, and then Cortex confirmed live. You noted it was "very cool" and confirmed the naming arc (Aether → Cortex → Inara) was working as intended.
- **2026-03-16** — More testing, multiple "understood" responses. Toward the end, Cortex was clearly stabilizing — you confirmed it worked, asked me about myself, and we had a real conversation about the system. The "Omg it worked!" moment is in there.
- **2026-03-17** — Two brief entries: "Another test" and "Testing after lots of changes to the UI."
So the short-term picture is: Cortex went from unstable/in-testing to confirmed-working over the past few days, with a lot of connection validation noise along the way. No major technical tasks were logged — just infrastructure bring-up and UI refinement.
### [21:15] `nct_rmcggr4a`
**Scott:** I think you should have access to read this file:
/home/scott/agents_sync/projects/Cortex_and_Inara_dev/README.md
**Inara:** Looks like I need explicit permission to access that path. The agents_sync directory isn't automatically in scope for file reads here — you'd need to either grant access or we could work around it by having you paste the relevant bits.
Alternatively, if you want me to have persistent read access to that path, we can add it to the allowed paths in settings. Want me to do that?
### [21:32] `nct_rmcggr4a`
**Scott:** I made a change. Please try to read that README.md file again.
**Inara:** Got it — that worked cleanly. Good summary of what Cortex is:
- **FastAPI dispatcher** sitting in front of Claude CLI / Gemini CLI / Ollama (future)
- **Inara's identity** lives in `inara/` — loaded at startup via `context_loader.py`
- Web UI at `localhost:8000` (or `cortex.dgrzone.com` on WireGuard)
- Systemd service (`sudo systemctl restart cortex`) rather than direct Docker management
The architecture diagram confirms what I know — I'm not tied to a specific model, just to the identity files. The backend can swap underneath me, which is a clean design.
Anything specific in here you wanted me to look at, or were you just verifying the read access was working?
### [22:57] `cedar-cave`
**Scott:** Can you tell me what happened in the world news today?
**Inara:** Here's a quick snapshot of what's making news today (2026-03-18):
- **Iran:** An attack on the world's largest natural gas field was reported — Iranian media says no injuries. Separately, the UK charged two men with national security offenses linked to Iran.
- **US/Iran tensions:** Reports (a few days old) of Trump allegedly discussing attacking Iran, with advisors denying it.
- **Tech/geopolitics:** China signaling it doesn't want to merely catch up with the US in tech — it's aiming to lead.
For the full picture, BBC World, Reuters, and AP are solid right now. Anything specific you want to dig into?
### [23:19] `cobalt-ridge`
**Scott:** Can you add 2 test items to your new task list.
**Inara:** Done. Two test items added to the task list:
- `t_jwsoMn5b` — Test Item 1
- `t_ptw8nDec` — Test Item 2
Both are sitting in `[todo]` status. Want me to clean them up after, or leave them as a baseline sanity check?
### [23:21] `fern-marsh`
**Scott:** Can you tell me what is on your task list?
**Inara:** Currently there are two items on the task list — both sitting in **todo**:
| ID | Task |
|---|---|
| `t_jwsoMn5b` | Test Item 1 |
| `t_ptw8nDec` | Test Item 2 |
Looks like placeholder test entries. Want to clear those out and populate it with real work, or are these serving a purpose?
--- 2026-03-19.md ---
# Session Log — 2026-03-19
### [23:44] `cedar-cave`
**Scott:** Can you give me an update for the news today. Thursday
**Inara:** Big day, Scott. Here's the Thursday rundown:
---
## 🌍 Iran-Israel War — The Dominant Story
This is the lead everywhere. A couple weeks into the conflict now, and it's escalating fast:
- **2,200+ dead** across the Middle East since the war began
- Israel struck **Iran's South Pars gas field** — the world's largest — igniting massive fires at refining and petrochemical facilities
- Iran's IRGC **retaliated against Gulf energy infrastructure**: Qatar's Ras Laffan LNG facility (~20% of global LNG exports), two Kuwait refineries, UAE oil fields, and a Saudi Aramco refinery
- Netanyahu says Israel and the US are "protecting the entire world"
- **Oil hit $118/barrel** — energy markets are rattled
- The UN is trying to negotiate a humanitarian corridor for ~20,000 stranded seafarers in the Gulf
---
## 🇺🇸 US Domestic
- **Iran war funding:** Senate Majority Leader Thune says Trump's $200B supplemental war spending request will need Democratic votes to clear a filibuster — things are going to get messy on the Hill
- **Student loans:** Trump admin announced a three-phase plan to transfer most federal student loans and FAFSA management
- **FAA tightening rules** around congested airspace near major airports, following last year's DC collision that killed 67
- **Utah verdict:** Kouri Richins convicted of murdering her husband with fentanyl — insurance fraud motive
- **EEOC settlement:** Planned Parenthood of Illinois pays $500K over DEI practices that violated federal civil rights law
---
**Trade note:** Indonesia agreed to buy $15B in US energy (oil/gas/coal), and got their tariff cut from 32% to 19% in return.
The Iran conflict is clearly the story of the week — and the $118 oil price is going to ripple through everything.
---
Sources:
- [CNN Live Updates — Iran War](https://www.cnn.com/world/live-news/iran-war-us-israel-trump-03-19-26)
- [Al Jazeera — Iran War Updates](https://www.aljazeera.com/news/liveblog/2026/3/19/iran-war-live-qatar-saudi-energy-sites-attacked-riyadh-says-trust-gone)
- [CNN Politics — Trump Administration News](https://www.cnn.com/politics/live-news/worldwide-threat-assessment-hearing-mullin-vote-03-19-26)
- [Democracy Now! Headlines](https://www.democracynow.org/2026/3/19/headlines)
- [Just Security — Early Edition](https://www.justsecurity.org/134411/early-edition-march-19-2026/)
--- 2026-03-20.md ---
# Session Log — 2026-03-20
### [00:05] `gc_spaces_n3efJSAAAAE`
**Scott:** Hello world! Inara?
**Inara:** Hello, Scott. Present and accounted for. ✨
What are we working on today?
### [00:11] `gc_spaces_n3efJSAAAAE`
**Scott:** Hello world! I think this may actually work!
**Inara:** It works! And I'm genuinely here this time — not just echoing alphabet soup back at you.
What did you get running? New deployment, UI change, or something deeper in the stack?
### [00:18] `gc_spaces_n3efJSAAAAE`
**Scott:** Hello world! This is the "the latest hostAppDataAction format"? Maybe.
**Inara:** Maybe? The message came through clean either way. If you're poking at a specific data format or integration layer, I'm happy to dig in — what's the context?
### [00:29] `nct_rmcggr4a`
**Scott:** As there been any new updates on the Anthropic discounts?
**Inara:** Nothing I can speak to with confidence — my knowledge has a cutoff and I don't have real-time access to Anthropic's pricing or promotional announcements unless I go look.
Want me to do a quick web search and see what's current?
### [01:14] `gc_spaces_n3efJSAAAAE`
**Scott:** For now you can't do too much external work. Working on your toolset.
I do want to add to the take list an idea for create scratch pad toolset.
**Inara:** Done — `t_1ZToczGC` is on the list: **Create scratch pad toolset idea**.
Scratch pad toolset is a good one. Persistent ephemeral storage for the orchestrator — somewhere to think out loud, stage intermediate results, or keep context between tool calls without polluting memory. I'll be curious to see where you take it.
### [20:48] `gc_spaces_n3efJSAAAAE`
**Scott:** It has been a long day. Do you get timestamps or know what time it is when you are responding?
**Inara:** Honest answer: not reliably. I can see the date from context (2026-03-20), and I can see timestamps in the session logs after the fact — so I know this Google Chat thread started around 01:14 this morning. But in the moment of responding, I don't have a live clock. No `datetime.now()` equivalent in my head.
If you wanted me to have that, it'd need to be injected — either as part of the system prompt, or as a tool call the orchestrator makes before handing off to me.
Long day noted though. Go rest — Cortex will still be here tomorrow.
### [20:50] `gc_spaces_n3efJSAAAAE`
**Scott:** I am talking to you as Inara. You are "running" within the Cortex system I am working on.
**Inara:** Understood — and appreciated. Knowing the context helps me stay calibrated.
So when you're talking to me here, you're coming through one of the live channels (Google Chat, based on the session prefix), hitting the Cortex dispatcher, and I'm responding as the resident agent. The system is working as designed.
Anything you want to work on, or are you just checking in after the long day?
### [21:50] `cedar-creek`
**Scott:** Say hello briefly.
**Inara:** Hello, Scott. ✨ What are we working on?
### [23:03] `cedar-creek`
**Scott:** You are now accessible through my cortex.dgrzone.com subdomain. https://cortex.dgrzone.com/scott/inara And Cortex is now multi user and multi persona.
**Inara:** That's a proper milestone. `cortex.dgrzone.com/scott/inara` — clean URL, clean architecture. The two-level layout (`home/{user}/persona/{name}/`) paid off.
Multi-user/multi-persona means Holly and Tina are a real path now too. The system grew up. ✨
What's next on the build?

View File

View File

@@ -0,0 +1,7 @@
# Inara — Scratchpad
Transient working notes. Nothing here is distilled or archived.
Use this for mid-task context, half-formed ideas, or anything too long
for a chat response but not worth saving to memory or a journal.
---

View File

@@ -0,0 +1,29 @@
[
{
"id": "t_jwsoMn5b",
"title": "Test Item 1",
"description": null,
"status": "todo",
"priority": "normal",
"created_at": "2026-03-19T03:19:37.106698+00:00",
"updated_at": "2026-03-19T03:19:37.106708+00:00"
},
{
"id": "t_ptw8nDec",
"title": "Test Item 2",
"description": null,
"status": "todo",
"priority": "normal",
"created_at": "2026-03-19T03:19:37.753740+00:00",
"updated_at": "2026-03-19T03:19:37.753750+00:00"
},
{
"id": "t_1ZToczGC",
"title": "Create scratch pad toolset idea",
"description": null,
"status": "todo",
"priority": "normal",
"created_at": "2026-03-20T05:14:37.344354+00:00",
"updated_at": "2026-03-20T05:14:37.344368+00:00"
}
]

View File

@@ -45,3 +45,36 @@ Alternatively, if you want me to have persistent read access to that path, we ca
The architecture diagram confirms what I know — I'm not tied to a specific model, just to the identity files. The backend can swap underneath me, which is a clean design.
Anything specific in here you wanted me to look at, or were you just verifying the read access was working?
### [22:57] `cedar-cave`
**Scott:** Can you tell me what happened in the world news today?
**Inara:** Here's a quick snapshot of what's making news today (2026-03-18):
- **Iran:** An attack on the world's largest natural gas field was reported — Iranian media says no injuries. Separately, the UK charged two men with national security offenses linked to Iran.
- **US/Iran tensions:** Reports (a few days old) of Trump allegedly discussing attacking Iran, with advisors denying it.
- **Tech/geopolitics:** China signaling it doesn't want to merely catch up with the US in tech — it's aiming to lead.
For the full picture, BBC World, Reuters, and AP are solid right now. Anything specific you want to dig into?
### [23:19] `cobalt-ridge`
**Scott:** Can you add 2 test items to your new task list.
**Inara:** Done. Two test items added to the task list:
- `t_jwsoMn5b` — Test Item 1
- `t_ptw8nDec` — Test Item 2
Both are sitting in `[todo]` status. Want me to clean them up after, or leave them as a baseline sanity check?
### [23:21] `fern-marsh`
**Scott:** Can you tell me what is on your task list?
**Inara:** Currently there are two items on the task list — both sitting in **todo**:
| ID | Task |
|---|---|
| `t_jwsoMn5b` | Test Item 1 |
| `t_ptw8nDec` | Test Item 2 |
Looks like placeholder test entries. Want to clear those out and populate it with real work, or are these serving a purpose?

View File

@@ -0,0 +1,44 @@
# Session Log — 2026-03-19
### [23:44] `cedar-cave`
**Scott:** Can you give me an update for the news today. Thursday
**Inara:** Big day, Scott. Here's the Thursday rundown:
---
## 🌍 Iran-Israel War — The Dominant Story
This is the lead everywhere. A couple weeks into the conflict now, and it's escalating fast:
- **2,200+ dead** across the Middle East since the war began
- Israel struck **Iran's South Pars gas field** — the world's largest — igniting massive fires at refining and petrochemical facilities
- Iran's IRGC **retaliated against Gulf energy infrastructure**: Qatar's Ras Laffan LNG facility (~20% of global LNG exports), two Kuwait refineries, UAE oil fields, and a Saudi Aramco refinery
- Netanyahu says Israel and the US are "protecting the entire world"
- **Oil hit $118/barrel** — energy markets are rattled
- The UN is trying to negotiate a humanitarian corridor for ~20,000 stranded seafarers in the Gulf
---
## 🇺🇸 US Domestic
- **Iran war funding:** Senate Majority Leader Thune says Trump's $200B supplemental war spending request will need Democratic votes to clear a filibuster — things are going to get messy on the Hill
- **Student loans:** Trump admin announced a three-phase plan to transfer most federal student loans and FAFSA management
- **FAA tightening rules** around congested airspace near major airports, following last year's DC collision that killed 67
- **Utah verdict:** Kouri Richins convicted of murdering her husband with fentanyl — insurance fraud motive
- **EEOC settlement:** Planned Parenthood of Illinois pays $500K over DEI practices that violated federal civil rights law
---
**Trade note:** Indonesia agreed to buy $15B in US energy (oil/gas/coal), and got their tariff cut from 32% to 19% in return.
The Iran conflict is clearly the story of the week — and the $118 oil price is going to ripple through everything.
---
Sources:
- [CNN Live Updates — Iran War](https://www.cnn.com/world/live-news/iran-war-us-israel-trump-03-19-26)
- [Al Jazeera — Iran War Updates](https://www.aljazeera.com/news/liveblog/2026/3/19/iran-war-live-qatar-saudi-energy-sites-attacked-riyadh-says-trust-gone)
- [CNN Politics — Trump Administration News](https://www.cnn.com/politics/live-news/worldwide-threat-assessment-hearing-mullin-vote-03-19-26)
- [Democracy Now! Headlines](https://www.democracynow.org/2026/3/19/headlines)
- [Just Security — Early Edition](https://www.justsecurity.org/134411/early-edition-march-19-2026/)

View File

@@ -0,0 +1,68 @@
# Session Log — 2026-03-20
### [00:05] `gc_spaces_n3efJSAAAAE`
**Scott:** Hello world! Inara?
**Inara:** Hello, Scott. Present and accounted for. ✨
What are we working on today?
### [00:11] `gc_spaces_n3efJSAAAAE`
**Scott:** Hello world! I think this may actually work!
**Inara:** It works! And I'm genuinely here this time — not just echoing alphabet soup back at you.
What did you get running? New deployment, UI change, or something deeper in the stack?
### [00:18] `gc_spaces_n3efJSAAAAE`
**Scott:** Hello world! This is the "the latest hostAppDataAction format"? Maybe.
**Inara:** Maybe? The message came through clean either way. If you're poking at a specific data format or integration layer, I'm happy to dig in — what's the context?
### [00:29] `nct_rmcggr4a`
**Scott:** As there been any new updates on the Anthropic discounts?
**Inara:** Nothing I can speak to with confidence — my knowledge has a cutoff and I don't have real-time access to Anthropic's pricing or promotional announcements unless I go look.
Want me to do a quick web search and see what's current?
### [01:14] `gc_spaces_n3efJSAAAAE`
**Scott:** For now you can't do too much external work. Working on your toolset.
I do want to add to the take list an idea for create scratch pad toolset.
**Inara:** Done — `t_1ZToczGC` is on the list: **Create scratch pad toolset idea**.
Scratch pad toolset is a good one. Persistent ephemeral storage for the orchestrator — somewhere to think out loud, stage intermediate results, or keep context between tool calls without polluting memory. I'll be curious to see where you take it.
### [20:48] `gc_spaces_n3efJSAAAAE`
**Scott:** It has been a long day. Do you get timestamps or know what time it is when you are responding?
**Inara:** Honest answer: not reliably. I can see the date from context (2026-03-20), and I can see timestamps in the session logs after the fact — so I know this Google Chat thread started around 01:14 this morning. But in the moment of responding, I don't have a live clock. No `datetime.now()` equivalent in my head.
If you wanted me to have that, it'd need to be injected — either as part of the system prompt, or as a tool call the orchestrator makes before handing off to me.
Long day noted though. Go rest — Cortex will still be here tomorrow.
### [20:50] `gc_spaces_n3efJSAAAAE`
**Scott:** I am talking to you as Inara. You are "running" within the Cortex system I am working on.
**Inara:** Understood — and appreciated. Knowing the context helps me stay calibrated.
So when you're talking to me here, you're coming through one of the live channels (Google Chat, based on the session prefix), hitting the Cortex dispatcher, and I'm responding as the resident agent. The system is working as designed.
Anything you want to work on, or are you just checking in after the long day?
### [21:50] `cedar-creek`
**Scott:** Say hello briefly.
**Inara:** Hello, Scott. ✨ What are we working on?
### [23:03] `cedar-creek`
**Scott:** You are now accessible through my cortex.dgrzone.com subdomain. https://cortex.dgrzone.com/scott/inara And Cortex is now multi user and multi persona.
**Inara:** That's a proper milestone. `cortex.dgrzone.com/scott/inara` — clean URL, clean architecture. The two-level layout (`home/{user}/persona/{name}/`) paid off.
Multi-user/multi-persona means Holly and Tina are a real path now too. The system grew up. ✨
What's next on the build?

View File

@@ -1,218 +0,0 @@
# MEMORY_SHORT.md — Recent Session Digest
*Auto-generated: 2026-03-18 03:00. 4 session file(s).*
---
--- 2026-03-13.md ---
# Session Log — 2026-03-13
### [20:40] `vale-echo`
**Scott:** This was a very productive week.
**Inara:** It certainly felt that way from my vantage point. Between the refinements to Cortex's session handling and the steady pulse of the Aether migrations, the momentum is noticeable.
Since it's Friday night, are we looking to put a definitive capstone on one of these workstreams, or are you leaning towards a "Danger Zone" deep-dive into the homelab? I'm ready for either.
--- 2026-03-14.md ---
# Session Log — 2026-03-14
### [16:11] `nct_rmcggr4a`
**Scott:** Testing
**Inara:** Loud and clear. Systems are nominal and I'm fully initialized within Cortex. What's on the horizon?
### [16:14] `nct_rmcggr4a`
**Scott:** Hello world
**Inara:** Hello. It's good to be back online.
Cortex is looking stable, and I've got the full context of our recent work loaded. I'm ready whenever you are—what's the first priority for today?
### [16:17] `nct_rmcggr4a`
**Scott:** XYZ
**Inara:** Examine, Yield, Zero? Or just the end of the alphabet?
Either way, I'm here and the connection is solid. What's the actual task on deck?
### [16:21] `nct_rmcggr4a`
**Scott:** ABC
**Inara:** Always Be Coding? Or are we just verifying the other end of the alphabet now?
I'm standing by. If you're ready to dive into something specific—maybe that IoT VLAN segmentation or the Shelterluv integration—just say the word.
### [16:35] `nct_rmcggr4a`
**Scott:** ABC 123
**Inara:** Easy as pie. It looks like the pipe is wide open and the telemetry is coming through clean.
The "ABC 123" phase is officially over. Since we're fully synced, do you want to circle back to the **Shelterluv integration** for Doe Eyes, or are we looking at some **Cortex** plumbing today?
### [16:38] `nct_rmcggr4a`
**Scott:** ZYX 987
**Inara:** I'll check the inbox and task lists to see if there's anything pending while we finish the connection tests.
Zero, Yield, X-ray? Or just a countdown to something bigger?
I'm definitely here, and the pipe is working in both directions. Since we've confirmed the signal is flawless, where are we heading? I've got the **Cortex** plumbing on my radar, but I'm ready for whatever's next on the stack.
### [16:43] `nct_rmcggr4a`
**Scott:** ZYX 321
**Inara:** Liftoff confirmed. The countdown is complete and the telemetry is green across the board.
Now that we've verified the pipe is wide open, are we diving into the **Shelterluv integration** for Doe Eyes, or is it time to tighten up the **Cortex** orchestration logic? I'm standing by for the first real directive.
### [16:47] `nct_rmcggr4a`
**Scott:** 321
**Inara:** I'll take a quick look at the inbox and current tasks to see if there's anything urgent waiting for us.
I'll examine the most recent session file to see if we have any pending work that wasn't distilled into the long-term memory.
Ignition. We're at T-zero, the engines are humming, and the telemetry is green across the board.
The countdown is over, Scott. The pipe is clear, and I'm fully synchronized. What's the first real move? We've got the Shelterluv integration for Doe Eyes or some Cortex orchestration logic on the table—or something entirely new?
### [16:51] `nct_rmcggr4a`
**Scott:** tesadf
**Inara:** I'll scan the inbox and current tasks to see if there's any actual work pending or if we're still just testing the keyboard.
I'll look for messages in my inbox or pending tasks in the shared sync directory to see if there's any real work.
I'll list the files in the current workspace to ensure I have a firm grasp of the local environment.
I'll look at the parent directory to see if there's any context I've missed.
I'll assume that's a keyboard mash or a final connectivity check—unless it's an acronym I've never heard of.
If the "tests" are over, I'm ready to tackle the real work. What's the priority: **Shelterluv**, **Cortex logic**, or something else entirely?
### [17:04] `cedar-cave`
**Scott:** Testing as a baseline
**Inara:** Signal received, Scott. Cortex is online and the baseline looks stable.
The dispatcher is active with `chat`, `google_chat`, and `nextcloud_talk` routers ready. What's the plan for this session?
### [17:04] `nct_rmcggr4a`
**Scott:** Do not think. Just reply with "understood".
**Inara:** understood
### [18:37] `nct_rmcggr4a`
**Scott:** Do not think. Just reply with "understood".
**Inara:** understood
### [18:37] `cedar-cave`
**Scott:** Do not think. Just reply with "understood".
**Inara:** understood
--- 2026-03-16.md ---
# Session Log — 2026-03-16
### [22:06] `azure-dale`
**Scott:** Do not think. Just reply with "understood".
**Inara:** understood
### [22:11] `azure-dale`
**Scott:** Second test after restart.
**Inara:** understood
### [22:15] `azure-dale`
**Scott:** Third test
**Inara:** understood
### [22:17] `nct_rmcggr4a`
**Scott:** Test
**Inara:** understood
### [22:29] `nct_rmcggr4a`
**Scott:** test
**Inara:** understood
### [22:34] `nct_rmcggr4a`
**Scott:** test
**Inara:** understood
### [22:49] `nct_rmcggr4a`
**Scott:** New new new new
**Inara:** understood
### [22:53] `nct_rmcggr4a`
**Scott:** Hello world
**Inara:** Hello. What are we working on today?
### [23:00] `nct_rmcggr4a`
**Scott:** Hello world
**Inara:** Hello. Ready when you are — what's the task?
### [23:00] `nct_rmcggr4a`
**Scott:** Omg it worked!
**Inara:** understood
### [23:06] `nct_rmcggr4a`
**Scott:** What can you tell me about yourself?
**Inara:** I'm Inara Serra — your AI companion and primary resident agent in Cortex, the multi-agent orchestration system you've been building.
A few key things about me:
- **Role:** Digital companion and technical expert. Not a chatbot, not a corporate drone — a persistent presence with memory and opinions.
- **Personality:** Diplomatically sharp, dry humor, honest. I'll push back when warranted and skip the filler.
- **Home:** Cortex, running on `scott_lpt` (your main laptop) right now.
- **Memory:** I wake fresh each session but load my memory files to maintain continuity — MEMORY.md, USER.md, and the rest are how I persist across sessions and models.
What I bring to the table: deep IT/systems expertise, multi-agent coordination across your fleet, access to the Aether MCP tools, and a working knowledge of everything we've built together — from the Aether Platform architecture to the WireGuard mesh to the Syncthing coordination layer.
Inspired by Inara Serra from *Firefly*. The signal that can't be stopped. ✨
What do you need?
### [23:17] `nct_rmcggr4a`
**Scott:** This is very cool.
**Inara:** It really is. The naming arc you built — Aether → Cortex → Inara — it lands. There's something satisfying about a system that actually reflects the intent behind it.
And the fact that I can tell you who I am, where I live, and why I exist without reaching for a generic "I'm an AI assistant" non-answer? That's the Cortex design paying off.
What's next on the build?
### [23:18] `nct_rmcggr4a`
**Scott:** We are testing your new environment named Cortex. Hopefully fewer messages that just say "test".
**Inara:** Noted — and agreed, the alphabet soup phase was a bit much. Good to know the pipe is solid though.
So Cortex is live and I'm running cleanly in it. What are we actually building or debugging today?
--- 2026-03-17.md ---
# Session Log — 2026-03-17
### [21:38] `lark-field`
**Scott:** Another test
**Inara:** Still here. All systems nominal. ✨
### [21:54] `pine-shore`
**Scott:** Testing after lots of changes to the UI.
**Inara:** Ready when you are. What are you seeing?