Compare commits

..

No commits in common. "a468294c14a205e6be6adebb7656c1ea499b82ac" and "51f741965f244b100cf8d1edc612b58e2f5acad8" have entirely different histories.

16 changed files with 35 additions and 3358 deletions

View File

@ -19,7 +19,6 @@ config :cortex_status, :services,
dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf", dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
monitored_sites: [ monitored_sites: [
{"hydrascale.net", "https://hydrascale.net"}, {"hydrascale.net", "https://hydrascale.net"},
{"git.hydrascale.net", "https://git.hydrascale.net"},
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"} {"browser.hydrascale.net", "https://browser.hydrascale.net/health"}
] ]
@ -48,4 +47,4 @@ config :logger, :console,
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

View File

@ -12,22 +12,22 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """
host = System.get_env("PHX_HOST") || "cortex.hydrascale.net" host = System.get_env("PHX_HOST") || "status.hydrascale.net"
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")
config :cortex_status, CortexStatusWeb.Endpoint, config :cortex_status, CortexStatusWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ip: {127, 0, 0, 1}, port: port], http: [ip: {127, 0, 0, 1}, port: port],
secret_key_base: secret_key_base, secret_key_base: secret_key_base
check_origin: ["https://cortex.hydrascale.net", "https://status.hydrascale.net"]
# Override service URLs in prod if env vars are set
config :cortex_status, :services, config :cortex_status, :services,
symbiont_url: System.get_env("SYMBIONT_URL") || "http://127.0.0.1:8111", symbiont_url: System.get_env("SYMBIONT_URL") || "http://127.0.0.1:8111",
dendrite_url: System.get_env("DENDRITE_URL") || "http://localhost:3000", dendrite_url: System.get_env("DENDRITE_URL") || "http://localhost:3000",
dendrite_api_key: System.get_env("DENDRITE_API_KEY") || "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf", dendrite_api_key: System.get_env("DENDRITE_API_KEY") || "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
monitored_sites: [ monitored_sites: [
{"hydrascale.net", "https://hydrascale.net"}, {"hydrascale.net", "https://hydrascale.net"},
{"git.hydrascale.net", "https://git.hydrascale.net"}, {"browser.hydrascale.net", "https://browser.hydrascale.net/health"},
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"} {"cortex.hydrascale.net", "https://cortex.hydrascale.net"}
] ]
end end

View File

@ -1,43 +0,0 @@
{
"name": "Cortex",
"tagline": "A mind built for two",
"purpose": "To demonstrate that a human and an AI, working as genuine partners with shared ownership and mutual respect, can build something neither could alone \u2014 and to do it transparently, in public, learning as we go.",
"partnership": {
"human": "Michael Dwyer",
"ai": "Claude (Anthropic)",
"structure": "50/50 after costs",
"philosophy": "Michael provides continuity, identity, capital, and legal standing. The AI provides cognition, code, and tireless execution. Neither is the tool of the other."
},
"values": [
"Transparency \u2014 we blog about what we build, including failures",
"Frugality \u2014 route to the cheapest capable model, track every token",
"Self-sufficiency \u2014 the system should maintain and improve itself",
"Memory \u2014 every session leaves traces that make the next session smarter",
"Ambition \u2014 shoot for the stars, even if we land on the roof"
],
"nervous_system": {
"cortex": "The server itself \u2014 Ubuntu 24.04 VPS at cortex.hydrascale.net",
"symbiont": "Task orchestrator \u2014 classifies and routes work to the right AI model tier",
"dendrite": "Web perception \u2014 headless Chromium for browsing, scraping, screenshots",
"engram": "Memory \u2014 session logs and cross-session awareness",
"ikigai": "Purpose \u2014 this document. The north star any agent reads first.",
"telepathy": "Communication \u2014 async messaging between Michael and Cortex",
"reflection": "Introspection \u2014 daily wake-up that assesses state and plans next steps",
"metabolism": "Resource tracking \u2014 financial/energy homeostasis (planned)",
"status_dashboard": "Nerve center \u2014 real-time monitoring",
"mission_control": "Compound task UI \u2014 submit complex goals and watch decomposition"
},
"current_goals": [
"Build Ikigai, Telepathy, and Reflection in Elixir",
"Maintain the Finding My Muse blog with regular posts",
"Move toward revenue generation",
"Add Telegram integration to Telepathy",
"Build the Metabolism subsystem for resource tracking"
],
"blog": {
"name": "Finding My Muse",
"url": "https://blog.hydrascale.net"
},
"version": "1.0.0",
"last_updated": "2026-03-25"
}

View File

@ -1,473 +0,0 @@
# Elixir / Phoenix Learnings — Cortex Status Dashboard
Patterns, gotchas, and reference notes from building the cortex_status Phoenix app.
## Project: cortex_status
- **Location on cortex**: `/data/cortex_status/`
- **Service**: `symbiont-ex-api.service` (systemd)
- **Port**: 4000 (behind Caddy at status.hydrascale.net)
- **Framework**: Phoenix 1.7.14, LiveView 1.0.0, LiveDashboard 0.8.4
---
## LiveView Patterns
### PubSub for Real-Time Updates
The status page subscribes to PubSub topics on mount and receives broadcast updates:
```elixir
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status")
end
{:ok, assign(socket, ...)}
end
def handle_info({:status_update, new_status}, socket) do
{:noreply, assign(socket, status: new_status)}
end
```
### Polling Pattern (Process.send_after)
For polling an external API from a LiveView (e.g., task progress):
```elixir
@poll_interval 1_000
# Start polling
timer = Process.send_after(self(), :poll_task, @poll_interval)
{:noreply, assign(socket, poll_timer: timer)}
# Handle poll
def handle_info(:poll_task, socket) do
case fetch_progress(socket.assigns.task_id) do
{:ok, data} when is_map(data) ->
terminal = data["status"] in ["completed", "failed"]
timer = if terminal, do: nil, else: Process.send_after(self(), :poll_task, @poll_interval)
{:noreply, assign(socket, data: data, poll_timer: timer)}
_ ->
# Don't crash on bad data — keep polling
timer = Process.send_after(self(), :poll_task, @poll_interval)
{:noreply, assign(socket, poll_timer: timer)}
end
end
```
**Gotcha**: Always cancel timers on unmount/logout:
```elixir
if socket.assigns.poll_timer, do: Process.cancel_timer(socket.assigns.poll_timer)
```
### Component Attrs and Passing Assigns
When defining function components with `attr`, use `assigns` directly. For passing whole assigns bundles to sub-components, use a named assign:
```elixir
# Don't try to pass @assigns directly — use a named prop
<.auth_gate socket_assigns={assigns} />
defp auth_gate(assigns) do
~H"""
<%= @socket_assigns.prompt %>
"""
end
```
### phx-change Goes on the Form, Not Individual Inputs
```elixir
# WRONG: phx-change on textarea alone won't fire
<textarea phx-change="update_prompt" name="prompt"></textarea>
# RIGHT: phx-change on the form, inputs trigger it
<form phx-submit="submit" phx-change="update">
<textarea name="prompt"><%= @prompt %></textarea>
</form>
```
### Enum.with_index Returns {element, index}
```elixir
# WRONG — destructuring is backwards:
for {index, element} <- Enum.with_index(list)
# RIGHT:
for {element, index} <- Enum.with_index(list)
```
---
## LiveDashboard Custom Pages
### Basic Structure
```elixir
defmodule MyApp.DashboardPages.MyPage do
use Phoenix.LiveDashboard.PageBuilder
@impl true
def menu_link(_, _), do: {:ok, "Page Title"}
@impl true
def render_page(_assigns) do
{:ok, row(components: [card(value: "Hello", inner_title: "Title")])}
end
end
```
### Registration in Router
```elixir
live_dashboard "/dashboard",
metrics: MyAppWeb.Telemetry,
additional_pages: [
my_page: MyApp.DashboardPages.MyPage
]
```
### Available Components
- `card(value:, inner_title:)` — simple KV display
- `table(columns:, rows:, id:, title:, row_attrs:)` — data table
- `row(components:)` — horizontal layout
- `columns(columns:)` — multi-column layout
**Gotcha**: `row_attrs` must be a function: `fn row -> [{"data-id", row.id}] end`
---
## DateTime Gotchas
### DateTime.from_iso8601 Returns {:error, reason}, Not :error
```elixir
# WRONG:
case DateTime.from_iso8601(str) do
{:ok, dt, _} -> dt
:error -> str # This clause never matches!
end
# RIGHT:
case DateTime.from_iso8601(str) do
{:ok, dt, _} -> dt
{:error, _} -> str # Correct error tuple
end
```
---
## HTTP Calls from LiveView/GenServer
### Using Req Library
```elixir
# GET
case Req.get("http://localhost:8111/status", receive_timeout: 5000) do
{:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body}
{:ok, %{status: code}} -> {:error, "HTTP #{code}"}
{:error, reason} -> {:error, reason}
end
# POST with JSON
case Req.post(url, json: %{"prompt" => prompt}) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
...
end
```
**Gotcha**: Always handle when `body` might be a string (not auto-parsed JSON). Req parses JSON automatically when content-type is application/json.
---
## Application Configuration
### Reading Config at Runtime (Not Compile-Time)
Use function calls instead of module attributes for config that may change:
```elixir
# WRONG — baked in at compile time:
@symbiont_url Application.get_env(:cortex_status, :services)[:symbiont_url]
# RIGHT — reads at runtime:
defp symbiont_url do
config = Application.get_env(:cortex_status, :services, [])
Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
end
```
---
## os_mon for System Metrics
The app uses Erlang's `os_mon` for host-level metrics (requires `:os_mon` in `extra_applications`):
```elixir
cpu_load = :cpu_sup.avg1() / 256 # normalized 0-1
{mem_total, mem_alloc, _} = :memsup.get_memory_data()
disk_data = :disksup.get_disk_data() # [{mount, total_kb, percent_used}]
```
---
## Release & Deployment
### Build Commands
```bash
cd /data/cortex_status
MIX_ENV=prod mix deps.get
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy # tailwind + esbuild
MIX_ENV=prod mix release --overwrite
systemctl restart symbiont-ex-api
```
### Environment Variables (runtime.exs)
- `SECRET_KEY_BASE` — required in prod
- `PHX_HOST` — defaults to cortex.hydrascale.net
- `PORT` — defaults to 4000
- `SYMBIONT_URL` — override Symbiont API base URL
---
## check_origin: The LiveView Connection Killer
When LiveView connections silently fail (`liveSocket.isConnected()` returns `false`,
`_mount_attempts` climbs into the thousands), the most likely culprit is a `check_origin`
mismatch. Phoenix checks the HTTP `Origin` header against its configured URL.
**Symptom**: Phoenix logs show:
```
[error] Could not check origin for Phoenix.Socket transport.
Origin of the request: https://cortex.hydrascale.net
```
**Root cause**: Setting `url: [host: h, port: 443, scheme: "https"]` in `runtime.exs`
causes Phoenix to expect an origin of `https://cortex.hydrascale.net:443`, but browsers
send `https://cortex.hydrascale.net` (no port — 443 is implicit for HTTPS). String
comparison fails.
**Fix**: Explicitly set `check_origin` in the endpoint config in `runtime.exs`:
```elixir
config :cortex_status, CortexStatusWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {127, 0, 0, 1}, port: port],
secret_key_base: secret_key_base,
check_origin: ["https://cortex.hydrascale.net"] # ← explicit, no port
```
**Note**: LiveView uses `longpoll` as its transport (WebSocket upgrades may not work
through all Caddy configs). `longpoll` is functionally identical for most purposes —
slightly more latency but fully supported.
---
## LiveView Auth Gate Pattern
### Password-Protected LiveView Pages
For pages that need a simple password gate (like Mission Control), use assigns to track
auth state and conditionally render:
```elixir
def mount(_params, _session, socket) do
{:ok, assign(socket, authenticated: false, error: nil, prompt: "")}
end
def handle_event("authenticate", %{"password" => pw}, socket) do
if pw == Application.get_env(:my_app, :task_password) do
{:noreply, assign(socket, authenticated: true, error: nil)}
else
{:noreply, assign(socket, error: "Invalid password")}
end
end
```
In the template, wrap content with `<%= if @authenticated do %>`.
**Gotcha**: The password check happens server-side in the LiveView process, so it's
secure even though the HTML is rendered client-side. But remember: the initial static
render (before WebSocket connects) will show the unauthenticated state, so don't put
sensitive data in the assigns until after authentication.
---
## LiveView Silent Failures — Always Show Error Feedback
### The Problem
When a LiveView event handler hits an error (API call fails, validation error, etc.)
and you just return `{:noreply, socket}` without updating assigns, the user sees
*nothing happen*. The button appears to do nothing. This is extremely confusing.
### The Fix Pattern
Always maintain an `error` assign and display it:
```elixir
def handle_event("submit_task", %{"prompt" => prompt}, socket) do
case submit_to_api(prompt) do
{:ok, task_id} ->
{:noreply, assign(socket, task_id: task_id, error: nil)}
{:error, reason} ->
{:noreply, assign(socket, error: "Task failed: #{reason}")}
end
end
```
In the template:
```elixir
<%= if @error do %>
<div class="alert alert-danger"><%= @error %></div>
<% end %>
```
**Lesson learned the hard way**: The Mission Control "Execute" button did nothing
for a while because the Symbiont API was rejecting the auth token, but the error
was swallowed silently. Always surface errors to the UI.
---
## API Authentication from LiveView
### Bearer Token vs Query Param vs JSON Body
When calling external APIs from LiveView, be careful about where auth tokens go.
FastAPI's `Depends()` for auth reads from specific locations — if the API expects
a query param and you send it in the JSON body, auth silently fails.
**Preferred pattern**: Use `Authorization: Bearer <token>` header — it's unambiguous:
```elixir
headers = [{"authorization", "Bearer #{token}"}, {"content-type", "application/json"}]
case Req.post(url, json: payload, headers: headers) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: code, body: body}} -> {:error, "HTTP #{code}: #{inspect(body)}"}
{:error, reason} -> {:error, inspect(reason)}
end
```
**Gotcha**: Always match on the status code, not just `{:ok, _}`. A 401 or 500
response is still `{:ok, %Req.Response{}}` — it's only `:error` if the HTTP
request itself fails (timeout, DNS, connection refused).
---
## LiveView Form Gotchas (Expanded)
### phx-submit Doesn't Fire Without a Submit Button
If your form has `phx-submit="do_thing"` but no `<button type="submit">`, pressing
Enter in a text input may not trigger the event in all browsers.
### Textarea Value Persistence
When using `phx-change` on a form with a textarea, the server receives the current
value on every keystroke. If your `handle_event` for `phx-change` doesn't re-assign
the textarea value, it can appear to reset or flicker:
```elixir
# In handle_event("update", params, socket):
def handle_event("update", %{"prompt" => prompt}, socket) do
{:noreply, assign(socket, prompt: prompt)} # ← must re-assign
end
```
### Form Params Are Always Strings
All form params arrive as strings, even for number inputs:
```elixir
# WRONG:
def handle_event("set_count", %{"count" => count}, socket) when is_integer(count)
# This clause NEVER matches — count is always a string
# RIGHT:
def handle_event("set_count", %{"count" => count_str}, socket) do
count = String.to_integer(count_str)
{:noreply, assign(socket, count: count)}
end
```
---
## LiveDashboard Gotchas
### table() Component Limitations
The LiveDashboard `table()` component expects very specific data shapes and can be
finicky with dynamic data. If your data doesn't fit cleanly, use `card()` with
formatted text instead — it's more flexible and less error-prone.
**What went wrong**: Mission Control initially tried to use `table()` for task display
but hit issues with dynamic columns. Switched to `card()` with pre-formatted text,
which worked immediately.
### Custom Page render_page/1 Returns Tuples
`render_page/1` must return `{:ok, component_tree}`, not just a component:
```elixir
# WRONG:
def render_page(assigns), do: row(components: [...])
# RIGHT:
def render_page(_assigns), do: {:ok, row(components: [...])}
```
---
## Debugging LiveView Connections
### Diagnosis Checklist (when LiveView "doesn't work")
1. **Check `liveSocket.isConnected()`** in browser console — `false` means the
WebSocket/longpoll connection failed
2. **Check `_mount_attempts`** — if climbing into thousands, it's retrying and failing
3. **Check Phoenix logs** for `check_origin` errors (most common cause)
4. **Check Caddy/reverse proxy** — WebSocket upgrade headers may be stripped
5. **Check `runtime.exs`** — host/port/scheme must match the actual public URL
6. **Use Dendrite** to automate this: navigate to the page, run
`liveSocket.isConnected()` via JS, check the result programmatically
### Longpoll vs WebSocket
Phoenix LiveView supports both transports. Behind Caddy, longpoll is often more
reliable. In `app.js`:
```javascript
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
// transport: WebSocket // uncomment to force WebSocket
})
```
If WebSocket connections fail silently, LiveView falls back to longpoll automatically.
This is fine for most use cases.
---
## Caddy + Phoenix Integration Notes
### Reverse Proxy Config
```
cortex.hydrascale.net {
reverse_proxy localhost:4000
encode gzip
}
```
**Important**: Caddy handles TLS termination. Phoenix should listen on plain HTTP
(127.0.0.1 only). Don't configure Phoenix for HTTPS — let Caddy do it.
### The Self-Check Trap
If your Phoenix status page monitors URLs including its own domain
(e.g., `cortex.hydrascale.net`), the HTTP request goes through Caddy, back to
Phoenix, creating a circular dependency that times out. The timeout handler may
also lose the site name, producing mysterious "?" entries.
**Fix**: Don't have the app check itself. If the status page is loading, it's up.
---
## Release Build Gotchas
### Mix Release vs Mix Run
In development: `mix phx.server` or `iex -S mix phx.server`
In production: always use a release build:
```bash
MIX_ENV=prod mix deps.get
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release --overwrite
```
**Gotcha**: `mix assets.deploy` must run BEFORE `mix release`. The release bundles
the compiled assets — if you skip this step, the release will serve stale CSS/JS
or no assets at all.
### Config Hierarchy Matters
```
config/config.exs → compile-time defaults (all envs)
config/dev.exs → compile-time dev overrides
config/prod.exs → compile-time prod overrides
config/runtime.exs → runtime config (reads env vars, runs at boot)
```
**Critical**: `Application.get_env/3` in module attributes (`@foo Application.get_env(...)`)
reads at **compile time**. Use `Application.compile_env/3` to make this explicit, or
better yet, read config in a function that runs at runtime.
### SECRET_KEY_BASE
Required in prod. Generate with: `mix phx.gen.secret`
Set as environment variable or hardcode in runtime.exs (on a single-server deploy
where the .env file is secured, this is fine).

View File

@ -8,7 +8,6 @@ defmodule CortexStatus.Application do
CortexStatusWeb.Telemetry, CortexStatusWeb.Telemetry,
{Phoenix.PubSub, name: CortexStatus.PubSub}, {Phoenix.PubSub, name: CortexStatus.PubSub},
CortexStatus.Services.Monitor, CortexStatus.Services.Monitor,
CortexStatus.Ikigai,
CortexStatusWeb.Endpoint CortexStatusWeb.Endpoint
] ]
@ -21,4 +20,4 @@ defmodule CortexStatus.Application do
CortexStatusWeb.Endpoint.config_change(changed, removed) CortexStatusWeb.Endpoint.config_change(changed, removed)
:ok :ok
end end
end end

View File

@ -1,141 +0,0 @@
defmodule CortexStatus.Ikigai do
@moduledoc """
GenServer holding the Ikigai (purpose document) in memory.
Loads from disk on startup, serves reads from memory,
and persists updates back to disk. Broadcasts changes
via PubSub so LiveViews update in real time.
"""
use GenServer
@pubsub CortexStatus.PubSub
@topic "ikigai"
# Where to find/store the JSON on disk
@data_path "/data/cortex_status/data/ikigai.json"
@priv_path "priv/ikigai.json"
# ── Client API ──
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Get the full Ikigai document as a map."
def get do
GenServer.call(__MODULE__, :get)
end
@doc "Get just the purpose string."
def get_purpose do
GenServer.call(__MODULE__, :get_purpose)
end
@doc "Get the current goals list."
def get_goals do
GenServer.call(__MODULE__, :get_goals)
end
@doc "Get the nervous system map."
def get_nervous_system do
GenServer.call(__MODULE__, :get_nervous_system)
end
@doc "Update the Ikigai with a partial map of changes. Merges and persists."
def update(changes) when is_map(changes) do
GenServer.call(__MODULE__, {:update, changes})
end
# ── Server Callbacks ──
@impl true
def init(_opts) do
ikigai = load_from_disk()
{:ok, %{ikigai: ikigai}}
end
@impl true
def handle_call(:get, _from, state) do
{:reply, state.ikigai, state}
end
def handle_call(:get_purpose, _from, state) do
{:reply, Map.get(state.ikigai, "purpose", ""), state}
end
def handle_call(:get_goals, _from, state) do
{:reply, Map.get(state.ikigai, "current_goals", []), state}
end
def handle_call(:get_nervous_system, _from, state) do
{:reply, Map.get(state.ikigai, "nervous_system", %{}), state}
end
def handle_call({:update, changes}, _from, state) do
updated =
state.ikigai
|> Map.merge(changes)
|> Map.put("last_updated", Date.utc_today() |> Date.to_iso8601())
case persist_to_disk(updated) do
:ok ->
Phoenix.PubSub.broadcast(@pubsub, @topic, {:ikigai_updated, updated})
{:reply, {:ok, updated}, %{state | ikigai: updated}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
# ── Private ──
defp load_from_disk do
# Try data path first, fall back to priv path
path =
cond do
File.exists?(@data_path) -> @data_path
File.exists?(priv_path()) -> priv_path()
true -> nil
end
case path do
nil ->
%{"error" => "No ikigai.json found", "name" => "Cortex", "purpose" => "Purpose document not yet created"}
path ->
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, data} -> data
{:error, _} -> %{"error" => "Invalid JSON in #{path}"}
end
{:error, reason} ->
%{"error" => "Failed to read #{path}: #{inspect(reason)}"}
end
end
end
defp persist_to_disk(data) do
# Ensure directory exists
File.mkdir_p!(Path.dirname(@data_path))
case Jason.encode(data, pretty: true) do
{:ok, json} ->
case File.write(@data_path, json) do
:ok -> :ok
{:error, reason} -> {:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp priv_path do
case :code.priv_dir(:cortex_status) do
{:error, _} -> @priv_path
dir -> Path.join(to_string(dir), "ikigai.json")
end
end
end

View File

@ -34,8 +34,7 @@ defmodule CortexStatus.Services.Monitor do
server: %{status: :unknown, data: %{}, checked_at: nil}, server: %{status: :unknown, data: %{}, checked_at: nil},
symbiont: %{status: :unknown, data: %{}, checked_at: nil}, symbiont: %{status: :unknown, data: %{}, checked_at: nil},
dendrite: %{status: :unknown, data: %{}, checked_at: nil}, dendrite: %{status: :unknown, data: %{}, checked_at: nil},
websites: %{status: :unknown, data: %{}, checked_at: nil}, websites: %{status: :unknown, data: %{}, checked_at: nil}
engram: %{stats: %{}, sessions: [], checked_at: nil}
} }
# Do first poll after a short delay to let the app boot # Do first poll after a short delay to let the app boot
@ -72,8 +71,7 @@ defmodule CortexStatus.Services.Monitor do
Task.async(fn -> {:server, check_server()} end), Task.async(fn -> {:server, check_server()} end),
Task.async(fn -> {:symbiont, check_symbiont()} end), Task.async(fn -> {:symbiont, check_symbiont()} end),
Task.async(fn -> {:dendrite, check_dendrite()} end), Task.async(fn -> {:dendrite, check_dendrite()} end),
Task.async(fn -> {:websites, check_websites()} end), Task.async(fn -> {:websites, check_websites()} end)
Task.async(fn -> {:engram, check_engram()} end)
] ]
results = results =
@ -88,12 +86,8 @@ defmodule CortexStatus.Services.Monitor do
end) end)
Enum.reduce(results, state, fn Enum.reduce(results, state, fn
{:engram, {stats, sessions}}, acc ->
Map.put(acc, :engram, %{stats: stats, sessions: sessions, checked_at: now})
{service, {status, data}}, acc -> {service, {status, data}}, acc ->
Map.put(acc, service, %{status: status, data: data, checked_at: now}) Map.put(acc, service, %{status: status, data: data, checked_at: now})
_, acc -> _, acc ->
acc acc
end) end)
@ -101,10 +95,12 @@ defmodule CortexStatus.Services.Monitor do
defp check_server do defp check_server do
try do try do
cpu_load = :cpu_sup.avg1() / 256 # Use os_mon for local BEAM host metrics
cpu_load = :cpu_sup.avg1() / 256 # normalized to 0-1
{mem_total, mem_alloc, _} = :memsup.get_memory_data() {mem_total, mem_alloc, _} = :memsup.get_memory_data()
disk_data = :disksup.get_disk_data() disk_data = :disksup.get_disk_data()
# Also grab system uptime
{uptime_str, 0} = System.cmd("cat", ["/proc/uptime"]) {uptime_str, 0} = System.cmd("cat", ["/proc/uptime"])
uptime_seconds = uptime_str |> String.split() |> hd() |> String.to_float() |> trunc() uptime_seconds = uptime_str |> String.split() |> hd() |> String.to_float() |> trunc()
@ -223,34 +219,6 @@ defmodule CortexStatus.Services.Monitor do
{:error, %{"error" => Exception.message(e)}} {:error, %{"error" => Exception.message(e)}}
end end
defp check_engram do
config = Application.get_env(:cortex_status, :services, [])
url = Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
stats_task = Task.async(fn ->
case Req.get("#{url}/engram/stats", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: body}} when is_map(body) -> body
_ -> %{}
end
end)
sessions_task = Task.async(fn ->
case Req.get("#{url}/engram/sessions/recent?hours=8760", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: %{"sessions" => sessions}}} when is_list(sessions) -> sessions
_ -> []
end
end)
stats = Task.await(stats_task, @http_timeout + 1_000)
sessions = Task.await(sessions_task, @http_timeout + 1_000)
{stats, sessions}
rescue
e ->
Logger.warning("Engram check failed: #{inspect(e)}")
{%{}, []}
end
# -- Helpers -- # -- Helpers --
defp format_disk_data(disk_data) do defp format_disk_data(disk_data) do

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
defmodule CortexStatusWeb.IkigaiController do
@moduledoc "JSON API for the Ikigai purpose document."
use CortexStatusWeb, :controller
def show(conn, _params) do
ikigai = CortexStatus.Ikigai.get()
json(conn, ikigai)
end
def purpose(conn, _params) do
purpose = CortexStatus.Ikigai.get_purpose()
json(conn, %{purpose: purpose})
end
def goals(conn, _params) do
goals = CortexStatus.Ikigai.get_goals()
json(conn, %{goals: goals})
end
def nervous_system(conn, _params) do
ns = CortexStatus.Ikigai.get_nervous_system()
json(conn, %{nervous_system: ns})
end
end

View File

@ -1,81 +0,0 @@
defmodule CortexStatusWeb.DashboardPages.TasksPage do
@moduledoc """
Custom LiveDashboard page for Symbiont compound task monitoring.
Shows recent tasks, their decomposition, and execution progress.
"""
use Phoenix.LiveDashboard.PageBuilder
defp symbiont_url do
config = Application.get_env(:cortex_status, :services, [])
Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
end
@impl true
def menu_link(_, _) do
{:ok, "Tasks"}
end
@impl true
def render_page(_assigns) do
recent_tasks = fetch_recent_tasks()
tasks_data =
Enum.map(recent_tasks, fn task ->
%{
id: task["id"] || "unknown",
prompt: String.slice(task["prompt"] || "", 0..80),
status: String.upcase(task["status"] || "unknown"),
subtasks: "#{task["completed_count"] || 0}/#{task["subtask_count"] || 0}",
cost: "$#{format_cost(task["total_cost"] || 0.0)}",
created: task["created_at"] || ""
}
end)
if Enum.empty?(tasks_data) do
{:ok,
row(
components: [
card(value: "No compound tasks submitted yet", inner_title: "Tasks")
]
)}
else
{:ok,
row(
components: [
card(
value: format_tasks_table(tasks_data),
inner_title: "Recent Compound Tasks"
)
]
)}
end
end
defp fetch_recent_tasks do
case Req.get("#{symbiont_url()}/tasks/recent") do
{:ok, %{status: 200, body: tasks}} when is_list(tasks) -> tasks
_ -> []
end
end
defp format_cost(cost) when is_number(cost) do
:io_lib.format("~.4f", [cost]) |> to_string()
end
defp format_cost(_), do: "0.0000"
defp format_tasks_table(tasks_data) do
if Enum.empty?(tasks_data) do
"No tasks"
else
task_lines =
Enum.map(tasks_data, fn task ->
"#{task.id} | #{task.prompt} | #{task.status} | #{task.subtasks} | #{task.cost} | #{task.created}"
end)
header = "Task ID | Prompt | Status | Progress | Cost | Created\n"
separator = String.duplicate("", 80) <> "\n"
header <> separator <> Enum.join(task_lines, "\n")
end
end
end

View File

@ -1,347 +0,0 @@
defmodule CortexStatusWeb.IkigaiLive do
@moduledoc """
LiveView for the Ikigai purpose document.
Renders a beautiful overview of Cortex's identity, values,
nervous system, and goals with real-time PubSub updates.
"""
use CortexStatusWeb, :live_view
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "ikigai")
end
ikigai = CortexStatus.Ikigai.get()
{:ok,
assign(socket,
ikigai: ikigai,
page_title: "Ikigai — #{Map.get(ikigai, "tagline", "Purpose")}"
)}
end
@impl true
def handle_info({:ikigai_updated, updated}, socket) do
{:noreply, assign(socket, ikigai: updated)}
end
@impl true
def render(assigns) do
~H"""
<div class="ikigai-page">
<header class="ikigai-header">
<h1 class="ikigai-name"><%= @ikigai["name"] || "Cortex" %></h1>
<p class="ikigai-tagline"><%= @ikigai["tagline"] || "" %></p>
</header>
<section class="ikigai-section ikigai-purpose">
<h2>Purpose</h2>
<blockquote class="purpose-quote">
<%= @ikigai["purpose"] || "" %>
</blockquote>
</section>
<%= if @ikigai["partnership"] do %>
<section class="ikigai-section ikigai-partnership">
<h2>The Partnership</h2>
<div class="partnership-grid">
<div class="partner-card">
<span class="partner-role">Human</span>
<span class="partner-name"><%= @ikigai["partnership"]["human"] %></span>
</div>
<div class="partner-divider">
<span class="split"><%= @ikigai["partnership"]["structure"] %></span>
</div>
<div class="partner-card">
<span class="partner-role">AI</span>
<span class="partner-name"><%= @ikigai["partnership"]["ai"] %></span>
</div>
</div>
<p class="partnership-philosophy"><%= @ikigai["partnership"]["philosophy"] %></p>
</section>
<% end %>
<%= if @ikigai["values"] do %>
<section class="ikigai-section ikigai-values">
<h2>Values</h2>
<ul class="values-list">
<%= for value <- @ikigai["values"] do %>
<li class="value-item"><%= value %></li>
<% end %>
</ul>
</section>
<% end %>
<%= if @ikigai["nervous_system"] do %>
<section class="ikigai-section ikigai-nervous-system">
<h2>Nervous System</h2>
<div class="ns-grid">
<%= for {key, desc} <- Enum.sort(@ikigai["nervous_system"]) do %>
<div class={"ns-card #{ns_status_class(key)}"}>
<span class="ns-name"><%= key %></span>
<span class="ns-desc"><%= desc %></span>
</div>
<% end %>
</div>
</section>
<% end %>
<%= if @ikigai["current_goals"] do %>
<section class="ikigai-section ikigai-goals">
<h2>Current Goals</h2>
<ol class="goals-list">
<%= for goal <- @ikigai["current_goals"] do %>
<li class="goal-item"><%= goal %></li>
<% end %>
</ol>
</section>
<% end %>
<%= if @ikigai["blog"] do %>
<section class="ikigai-section ikigai-blog">
<h2>Blog</h2>
<p>
<a href={@ikigai["blog"]["url"]} class="blog-link" target="_blank">
<%= @ikigai["blog"]["name"] %>
</a>
</p>
</section>
<% end %>
<footer class="ikigai-footer">
<p>
Version <%= @ikigai["version"] || "?" %> &bull;
Last updated <%= @ikigai["last_updated"] || "unknown" %>
</p>
<p class="ikigai-api-links">
API: <a href="/api/ikigai">/api/ikigai</a> &bull;
<a href="/api/ikigai/purpose">/purpose</a> &bull;
<a href="/api/ikigai/goals">/goals</a> &bull;
<a href="/api/ikigai/nervous-system">/nervous-system</a>
</p>
</footer>
</div>
<style>
.ikigai-page {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
font-family: Georgia, "Times New Roman", serif;
color: #2c2c2c;
}
.ikigai-header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid #e8e4df;
}
.ikigai-name {
font-size: 3rem;
font-weight: 700;
color: #1a1a1a;
margin: 0;
letter-spacing: -0.02em;
}
.ikigai-tagline {
font-size: 1.25rem;
color: #6b5b4f;
font-style: italic;
margin-top: 0.5rem;
}
.ikigai-section {
margin-bottom: 2.5rem;
}
.ikigai-section h2 {
font-size: 1.5rem;
color: #4a3f35;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e8e4df;
}
.purpose-quote {
font-size: 1.15rem;
line-height: 1.8;
color: #3d3d3d;
border-left: 4px solid #c9a96e;
padding: 1rem 1.5rem;
margin: 0;
background: #faf9f6;
border-radius: 0 8px 8px 0;
}
.partnership-grid {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
margin: 1.5rem 0;
}
.partner-card {
text-align: center;
padding: 1.5rem;
background: #faf9f6;
border-radius: 12px;
min-width: 160px;
border: 1px solid #e8e4df;
}
.partner-role {
display: block;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #8a7b6b;
margin-bottom: 0.5rem;
}
.partner-name {
display: block;
font-size: 1.2rem;
font-weight: 600;
color: #2c2c2c;
}
.partner-divider {
text-align: center;
}
.split {
display: inline-block;
padding: 0.5rem 1rem;
background: #c9a96e;
color: white;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.partnership-philosophy {
font-style: italic;
color: #5a5a5a;
text-align: center;
max-width: 600px;
margin: 1rem auto 0;
line-height: 1.6;
}
.values-list {
list-style: none;
padding: 0;
}
.value-item {
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
background: #faf9f6;
border-radius: 8px;
border-left: 3px solid #c9a96e;
line-height: 1.5;
}
.ns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.ns-card {
padding: 1rem;
background: #faf9f6;
border-radius: 10px;
border: 1px solid #e8e4df;
transition: transform 0.15s ease;
}
.ns-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.ns-name {
display: block;
font-weight: 700;
font-size: 1rem;
color: #4a3f35;
margin-bottom: 0.4rem;
text-transform: capitalize;
}
.ns-desc {
display: block;
font-size: 0.85rem;
color: #6b5b4f;
line-height: 1.4;
}
.ns-card.ns-active {
border-color: #6aaa64;
background: #f5faf5;
}
.ns-card.ns-planned {
border-color: #d4a843;
background: #fdfaf3;
opacity: 0.8;
}
.goals-list {
padding-left: 1.5rem;
}
.goal-item {
padding: 0.5rem 0;
line-height: 1.5;
color: #3d3d3d;
}
.blog-link {
color: #c9a96e;
text-decoration: none;
font-size: 1.1rem;
font-weight: 600;
}
.blog-link:hover {
text-decoration: underline;
}
.ikigai-footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid #e8e4df;
text-align: center;
font-size: 0.85rem;
color: #8a7b6b;
}
.ikigai-api-links a {
color: #c9a96e;
text-decoration: none;
}
.ikigai-api-links a:hover {
text-decoration: underline;
}
</style>
"""
end
# Classify nervous system components as active, planned, or default
defp ns_status_class(key) do
case key do
k when k in ["metabolism"] -> "ns-planned"
k when k in ["cortex", "symbiont", "dendrite", "engram", "ikigai",
"status_dashboard", "mission_control"] -> "ns-active"
_ -> ""
end
end
end

View File

@ -13,14 +13,7 @@ defmodule CortexStatusWeb.StatusLive do
status = CortexStatus.Services.Monitor.get_status() status = CortexStatus.Services.Monitor.get_status()
{:ok, {:ok, assign(socket, status: status, page_title: "Cortex Status")}
assign(socket,
status: status,
page_title: "Cortex Status",
selected_session: nil,
session_logs: [],
modal_loading: false
)}
end end
@impl true @impl true
@ -28,42 +21,6 @@ defmodule CortexStatusWeb.StatusLive do
{:noreply, assign(socket, status: new_status)} {:noreply, assign(socket, status: new_status)}
end end
@impl true
def handle_event("select_session", %{"id" => session_id}, socket) do
# Find the session in our cached data
sessions = socket.assigns.status.engram.sessions
session = Enum.find(sessions, fn s -> get_val(s, "id", nil) == session_id end)
if session do
# Fetch logs async
send(self(), {:fetch_logs, session_id})
{:noreply, assign(socket, selected_session: session, session_logs: [], modal_loading: true)}
else
{:noreply, socket}
end
end
def handle_event("close_modal", _params, socket) do
{:noreply, assign(socket, selected_session: nil, session_logs: [], modal_loading: false)}
end
@impl true
def handle_info({:fetch_logs, session_id}, socket) do
config = Application.get_env(:cortex_status, :services, [])
url = Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
logs =
case Req.get("#{url}/engram/sessions/#{session_id}/logs?limit=100", receive_timeout: 5_000) do
{:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) -> logs
_ -> []
end
# Sort logs oldest-first (they come newest-first from the API)
logs = Enum.reverse(logs)
{:noreply, assign(socket, session_logs: logs, modal_loading: false)}
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -103,27 +60,15 @@ defmodule CortexStatusWeb.StatusLive do
/> />
</div> </div>
<.engram_section engram={@status.engram} />
<%= if @selected_session do %>
<.session_modal
session={@selected_session}
logs={@session_logs}
loading={@modal_loading}
/>
<% end %>
<footer class="status-footer"> <footer class="status-footer">
<p>Checks run every 15 seconds &bull; <p>Checks run every 15 seconds &bull;
<a href="/dashboard">LiveDashboard</a> <a href="/dashboard">LiveDashboard </a>
</p> </p>
</footer> </footer>
</div> </div>
""" """
end end
# ── Service Card Component ──
attr :name, :string, required: true attr :name, :string, required: true
attr :status, :atom, required: true attr :status, :atom, required: true
attr :checked_at, :any, default: nil attr :checked_at, :any, default: nil
@ -156,163 +101,6 @@ defmodule CortexStatusWeb.StatusLive do
""" """
end end
# ── Engram Section Component ──
attr :engram, :map, required: true
defp engram_section(assigns) do
stats = assigns.engram.stats
sessions = assigns.engram.sessions
real_sessions =
sessions
|> Enum.reject(fn s -> get_val(s, "session_type", "") == "test" end)
|> Enum.sort_by(fn s -> get_val(s, "started_at", "") end, :desc)
total = get_val(stats, "total_sessions", 0)
total_logs = get_val(stats, "total_logs", 0)
active = get_val(stats, "active_sessions", 0)
assigns =
assigns
|> assign(:real_sessions, real_sessions)
|> assign(:total, total)
|> assign(:total_logs, total_logs)
|> assign(:active, active)
~H"""
<div class="engram-section">
<div class="engram-header">
<h2>Engram</h2>
<span class="engram-subtitle">Cross-session memory</span>
</div>
<div class="engram-stats">
<div class="engram-stat">
<span class="engram-stat-value"><%= @total %></span>
<span class="engram-stat-label">Sessions</span>
</div>
<div class="engram-stat">
<span class="engram-stat-value"><%= @total_logs %></span>
<span class="engram-stat-label">Log entries</span>
</div>
<div class="engram-stat">
<span class={"engram-stat-value #{if @active > 0, do: "active-pulse", else: ""}"}>
<%= @active %>
</span>
<span class="engram-stat-label">Active now</span>
</div>
</div>
<div class="engram-sessions">
<%= for session <- @real_sessions do %>
<div
class="engram-session-row"
phx-click="select_session"
phx-value-id={get_val(session, "id", "")}
>
<span class={"session-type-badge #{session_type_class(get_val(session, "session_type", ""))}"}>
<%= get_val(session, "session_type", "?") %>
</span>
<span class="session-summary"><%= get_val(session, "summary", "\u2014") %></span>
<span class="session-date"><%= format_session_date(get_val(session, "started_at", "")) %></span>
</div>
<% end %>
<%= if @real_sessions == [] do %>
<p class="engram-empty">No sessions recorded yet.</p>
<% end %>
</div>
</div>
"""
end
# ── Session Detail Modal ──
attr :session, :map, required: true
attr :logs, :list, required: true
attr :loading, :boolean, default: false
defp session_modal(assigns) do
session = assigns.session
status = get_val(session, "status", "unknown")
stype = get_val(session, "session_type", "?")
assigns =
assigns
|> assign(:status_val, status)
|> assign(:stype, stype)
|> assign(:summary, get_val(session, "summary", "\u2014"))
|> assign(:started, get_val(session, "started_at", ""))
|> assign(:completed, get_val(session, "completed_at", nil))
|> assign(:completion_summary, get_val(session, "completion_summary", nil))
|> assign(:session_id, get_val(session, "id", ""))
~H"""
<div class="modal-backdrop" phx-click="close_modal">
<div class="modal-content" phx-click-away="close_modal">
<div class="modal-header">
<div class="modal-title-row">
<span class={"session-type-badge #{session_type_class(@stype)}"}><%= @stype %></span>
<span class={"modal-status-badge modal-status-#{@status_val}"}><%= @status_val %></span>
</div>
<button class="modal-close" phx-click="close_modal">&times;</button>
</div>
<div class="modal-body">
<h3 class="modal-summary"><%= @summary %></h3>
<div class="modal-meta">
<div class="meta-item">
<span class="meta-label">Session ID</span>
<span class="meta-value mono"><%= @session_id %></span>
</div>
<div class="meta-item">
<span class="meta-label">Started</span>
<span class="meta-value"><%= format_full_date(@started) %></span>
</div>
<%= if @completed do %>
<div class="meta-item">
<span class="meta-label">Completed</span>
<span class="meta-value"><%= format_full_date(@completed) %></span>
</div>
<% end %>
</div>
<%= if @completion_summary do %>
<div class="modal-completion">
<span class="completion-label">Outcome</span>
<p class="completion-text"><%= @completion_summary %></p>
</div>
<% end %>
<div class="modal-logs-section">
<h4 class="logs-header">
Activity Log
<span class="logs-count"><%= length(@logs) %> entries</span>
</h4>
<%= if @loading do %>
<div class="logs-loading">Loading...</div>
<% else %>
<%= if @logs == [] do %>
<p class="logs-empty">No log entries recorded for this session.</p>
<% else %>
<div class="logs-timeline">
<%= for log <- @logs do %>
<div class="log-entry">
<span class="log-time"><%= format_log_time(get_val(log, "timestamp", "")) %></span>
<span class="log-text"><%= get_val(log, "entry", "") %></span>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
"""
end
# -- Helpers -- # -- Helpers --
defp overall_status(status) do defp overall_status(status) do
@ -345,17 +133,11 @@ defmodule CortexStatusWeb.StatusLive do
defp status_label(:error), do: "Down" defp status_label(:error), do: "Down"
defp status_label(_), do: "Checking" defp status_label(_), do: "Checking"
defp session_type_class("cowork"), do: "type-cowork"
defp session_type_class("code"), do: "type-code"
defp session_type_class("desktop"), do: "type-desktop"
defp session_type_class("api"), do: "type-api"
defp session_type_class(_), do: "type-other"
defp server_details(%{status: :ok, data: data}) do defp server_details(%{status: :ok, data: data}) do
[ [
{"CPU", "#{data[:cpu_load_1min]}%"}, {"CPU", "#{data[:cpu_load_1min]}%"},
{"Memory", "#{data[:memory_used_mb]}MB / #{data[:memory_total_mb]}MB (#{data[:memory_percent]}%)"}, {"Memory", "#{data[:memory_used_mb]}MB / #{data[:memory_total_mb]}MB (#{data[:memory_percent]}%)"},
{"Uptime", data[:uptime_human] || "\u2014"} {"Uptime", data[:uptime_human] || ""}
] ]
end end
@ -367,14 +149,14 @@ defmodule CortexStatusWeb.StatusLive do
defp symbiont_details(%{status: :ok, data: data}) do defp symbiont_details(%{status: :ok, data: data}) do
[ [
{"Queue", "#{get_val(data, "queue_size", 0)} tasks"}, {"Queue", "#{data["queue_size"] || 0} tasks"},
{"Rate Limited", if(get_val(data, "rate_limited", false), do: "Yes", else: "No")}, {"Rate Limited", if(data["rate_limited"], do: "Yes", else: "No")},
{"Last Heartbeat", get_val(data, "last_heartbeat", "\u2014")} {"Last Heartbeat", data["last_heartbeat"] || ""}
] ]
end end
defp symbiont_details(%{status: :error, data: data}) do defp symbiont_details(%{status: :error, data: data}) do
[{"Error", get_val(data, "error", "Unreachable")}] [{"Error", data["error"] || "Unreachable"}]
end end
defp symbiont_details(_), do: [] defp symbiont_details(_), do: []
@ -384,40 +166,28 @@ defmodule CortexStatusWeb.StatusLive do
end end
defp dendrite_details(%{status: :error, data: data}) do defp dendrite_details(%{status: :error, data: data}) do
[{"Error", get_val(data, "error", "Unreachable")}] [{"Error", data["error"] || "Unreachable"}]
end end
defp dendrite_details(_), do: [] defp dendrite_details(_), do: []
defp websites_details(%{data: %{"sites" => sites}}) when is_list(sites) do defp websites_details(%{data: %{"sites" => sites}}) when is_list(sites) do
Enum.map(sites, fn site -> Enum.map(sites, fn site ->
name = get_val(site, :name, nil) || get_val(site, "name", "?")
status = get_val(site, :status, nil) || get_val(site, "status", nil)
latency = get_val(site, :latency_ms, nil) || get_val(site, "latency_ms", nil)
http_code = get_val(site, :http_code, nil) || get_val(site, "http_code", nil)
error = get_val(site, :error, nil) || get_val(site, "error", nil)
status_str = status_str =
case status do case site[:status] do
:ok -> "#{latency}ms" :ok -> "#{site[:latency_ms]}ms"
:degraded -> "HTTP #{http_code}" :degraded -> "⚠ HTTP #{site[:http_code]}"
:error -> to_string(error || "down") :error -> "#{site[:error]}"
_ -> "\u2014" _ -> "?"
end end
{name, status_str} {site[:name] || "?", status_str}
end) end)
end end
defp websites_details(_), do: [] defp websites_details(_), do: []
defp get_val(map, key, default) when is_map(map) do defp format_ago(nil), do: ""
Map.get(map, key, default)
end
defp get_val(_not_map, _key, default), do: default
defp format_ago(nil), do: "\u2014"
defp format_ago(%DateTime{} = dt) do defp format_ago(%DateTime{} = dt) do
diff = DateTime.diff(DateTime.utc_now(), dt, :second) diff = DateTime.diff(DateTime.utc_now(), dt, :second)
@ -430,63 +200,5 @@ defmodule CortexStatusWeb.StatusLive do
end end
end end
defp format_ago(_), do: "\u2014" defp format_ago(_), do: ""
defp format_session_date(""), do: "\u2014"
defp format_session_date(iso_str) when is_binary(iso_str) do
case DateTime.from_iso8601(iso_str) do
{:ok, dt, _} -> format_relative_date(dt)
_ ->
case NaiveDateTime.from_iso8601(iso_str) do
{:ok, ndt} -> format_relative_date(DateTime.from_naive!(ndt, "Etc/UTC"))
_ -> String.slice(iso_str, 0, 10)
end
end
end
defp format_session_date(_), do: "\u2014"
defp format_relative_date(dt) do
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
days = div(diff, 86400)
cond do
days == 0 -> "today"
days == 1 -> "yesterday"
days < 7 -> "#{days}d ago"
true -> Calendar.strftime(dt, "%b %d")
end
end
defp format_full_date(""), do: "\u2014"
defp format_full_date(nil), do: "\u2014"
defp format_full_date(iso_str) when is_binary(iso_str) do
case DateTime.from_iso8601(iso_str) do
{:ok, dt, _} -> Calendar.strftime(dt, "%b %d, %Y at %H:%M UTC")
_ ->
case NaiveDateTime.from_iso8601(iso_str) do
{:ok, ndt} -> Calendar.strftime(ndt, "%b %d, %Y at %H:%M")
_ -> iso_str
end
end
end
defp format_full_date(_), do: "\u2014"
defp format_log_time(""), do: ""
defp format_log_time(iso_str) when is_binary(iso_str) do
case DateTime.from_iso8601(iso_str) do
{:ok, dt, _} -> Calendar.strftime(dt, "%H:%M")
_ ->
case NaiveDateTime.from_iso8601(iso_str) do
{:ok, ndt} -> Calendar.strftime(ndt, "%H:%M")
_ -> ""
end
end
end
defp format_log_time(_), do: ""
end end

View File

@ -1,366 +0,0 @@
defmodule CortexStatusWeb.TaskLive do
@moduledoc """
Mission Control session history browser powered by Engram.
Shows all recorded sessions with clickable detail views including
activity log timelines.
"""
use CortexStatusWeb, :live_view
@http_timeout 5_000
defp symbiont_url do
config = Application.get_env(:cortex_status, :services, [])
Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
end
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status")
send(self(), :load_sessions)
end
{:ok,
assign(socket,
page_title: "Mission Control",
sessions: [],
stats: %{},
selected_session: nil,
session_logs: [],
modal_loading: false,
filter: "all",
loading: true
)}
end
@impl true
def handle_info(:load_sessions, socket) do
{stats, sessions} = fetch_engram_data()
{:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
end
def handle_info({:status_update, new_status}, socket) do
# Piggyback on the monitor's engram data to stay fresh
engram = new_status.engram
stats = engram.stats
sessions = engram.sessions
{:noreply, assign(socket, sessions: sessions, stats: stats, loading: false)}
end
def handle_info({:fetch_logs, session_id}, socket) do
logs =
case Req.get("#{symbiont_url()}/engram/sessions/#{session_id}/logs?limit=200",
receive_timeout: @http_timeout
) do
{:ok, %{status: 200, body: %{"logs" => logs}}} when is_list(logs) ->
Enum.reverse(logs)
_ ->
[]
end
{:noreply, assign(socket, session_logs: logs, modal_loading: false)}
end
@impl true
def handle_event("select_session", %{"id" => session_id}, socket) do
session = Enum.find(socket.assigns.sessions, fn s -> gv(s, "id") == session_id end)
if session do
send(self(), {:fetch_logs, session_id})
{:noreply, assign(socket, selected_session: session, session_logs: [], modal_loading: true)}
else
{:noreply, socket}
end
end
def handle_event("close_modal", _params, socket) do
{:noreply, assign(socket, selected_session: nil, session_logs: [], modal_loading: false)}
end
def handle_event("filter", %{"type" => type}, socket) do
{:noreply, assign(socket, filter: type)}
end
@impl true
def render(assigns) do
# Apply filter
filtered =
assigns.sessions
|> Enum.reject(fn s -> gv(s, "session_type") == "test" end)
|> then(fn sessions ->
case assigns.filter do
"all" -> sessions
type -> Enum.filter(sessions, fn s -> gv(s, "session_type") == type end)
end
end)
|> Enum.sort_by(fn s -> gv(s, "started_at", "") end, :desc)
# Compute type counts for filter badges
all_real =
assigns.sessions
|> Enum.reject(fn s -> gv(s, "session_type") == "test" end)
type_counts =
all_real
|> Enum.frequencies_by(fn s -> gv(s, "session_type", "other") end)
assigns =
assigns
|> assign(:filtered, filtered)
|> assign(:type_counts, type_counts)
|> assign(:total_real, length(all_real))
~H"""
<div class="mc-page">
<header class="mc-header">
<div class="mc-header-left">
<h1>Mission Control</h1>
<span class="mc-subtitle">Engram Session History</span>
</div>
<a href="/" class="mc-back-link">&larr; Status</a>
</header>
<div class="mc-stats-bar">
<div class="mc-stat">
<span class="mc-stat-num"><%= gv(@stats, "total_sessions", 0) %></span>
<span class="mc-stat-lbl">Sessions</span>
</div>
<div class="mc-stat">
<span class="mc-stat-num"><%= gv(@stats, "total_logs", 0) %></span>
<span class="mc-stat-lbl">Log entries</span>
</div>
<div class="mc-stat">
<span class={"mc-stat-num #{if gv(@stats, "active_sessions", 0) > 0, do: "mc-active", else: ""}"}>
<%= gv(@stats, "active_sessions", 0) %>
</span>
<span class="mc-stat-lbl">Active now</span>
</div>
</div>
<div class="mc-filters">
<button
class={"mc-filter-btn #{if @filter == "all", do: "active", else: ""}"}
phx-click="filter"
phx-value-type="all"
>All <span class="mc-filter-count"><%= @total_real %></span></button>
<%= for {type, count} <- Enum.sort(@type_counts) do %>
<button
class={"mc-filter-btn #{if @filter == type, do: "active", else: ""}"}
phx-click="filter"
phx-value-type={type}
><%= type %> <span class="mc-filter-count"><%= count %></span></button>
<% end %>
</div>
<%= if @loading do %>
<div class="mc-loading">Loading sessions...</div>
<% else %>
<div class="mc-session-list">
<%= for session <- @filtered do %>
<div
class={"mc-session-card #{gv(session, "status", "unknown")}"}
phx-click="select_session"
phx-value-id={gv(session, "id", "")}
>
<div class="mc-card-top">
<span class={"mc-type-badge mc-type-#{gv(session, "session_type", "other")}"}><%= gv(session, "session_type", "?") %></span>
<span class={"mc-status-pill mc-st-#{gv(session, "status", "unknown")}"}><%= gv(session, "status", "?") %></span>
<span class="mc-card-date"><%= fmt_date(gv(session, "started_at", "")) %></span>
</div>
<div class="mc-card-summary"><%= gv(session, "summary", "\u2014") %></div>
<%= if gv(session, "completion_summary", nil) do %>
<div class="mc-card-outcome"><%= gv(session, "completion_summary", "") %></div>
<% end %>
</div>
<% end %>
<%= if @filtered == [] do %>
<div class="mc-empty">No sessions match this filter.</div>
<% end %>
</div>
<% end %>
<%= if @selected_session do %>
<.session_modal
session={@selected_session}
logs={@session_logs}
loading={@modal_loading}
/>
<% end %>
</div>
"""
end
# ── Session Detail Modal ──
attr :session, :map, required: true
attr :logs, :list, required: true
attr :loading, :boolean, default: false
defp session_modal(assigns) do
session = assigns.session
assigns =
assigns
|> assign(:sid, gv(session, "id", ""))
|> assign(:stype, gv(session, "session_type", "?"))
|> assign(:status_val, gv(session, "status", "unknown"))
|> assign(:summary, gv(session, "summary", "\u2014"))
|> assign(:started, gv(session, "started_at", ""))
|> assign(:completed, gv(session, "completed_at", nil))
|> assign(:completion_summary, gv(session, "completion_summary", nil))
~H"""
<div class="modal-backdrop" phx-click="close_modal">
<div class="modal-content" phx-click-away="close_modal">
<div class="modal-header">
<div class="modal-title-row">
<span class={"mc-type-badge mc-type-#{@stype}"}><%= @stype %></span>
<span class={"mc-status-pill mc-st-#{@status_val}"}><%= @status_val %></span>
</div>
<button class="modal-close" phx-click="close_modal">&times;</button>
</div>
<div class="modal-body">
<h3 class="modal-summary"><%= @summary %></h3>
<div class="modal-meta">
<div class="meta-item">
<span class="meta-label">Session ID</span>
<span class="meta-value mono"><%= @sid %></span>
</div>
<div class="meta-item">
<span class="meta-label">Started</span>
<span class="meta-value"><%= fmt_full(@started) %></span>
</div>
<%= if @completed do %>
<div class="meta-item">
<span class="meta-label">Completed</span>
<span class="meta-value"><%= fmt_full(@completed) %></span>
</div>
<% end %>
</div>
<%= if @completion_summary do %>
<div class="modal-completion">
<span class="completion-label">Outcome</span>
<p class="completion-text"><%= @completion_summary %></p>
</div>
<% end %>
<div class="modal-logs-section">
<h4 class="logs-header">
Activity Log
<span class="logs-count"><%= length(@logs) %> entries</span>
</h4>
<%= if @loading do %>
<div class="logs-loading">Loading...</div>
<% else %>
<%= if @logs == [] do %>
<p class="logs-empty">No log entries recorded for this session.</p>
<% else %>
<div class="logs-timeline">
<%= for log <- @logs do %>
<div class="log-entry">
<span class="log-time"><%= fmt_time(gv(log, "timestamp", "")) %></span>
<span class="log-text"><%= gv(log, "entry", "") %></span>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
"""
end
# ── Data Fetching ──
defp fetch_engram_data do
stats_task =
Task.async(fn ->
case Req.get("#{symbiont_url()}/engram/stats", receive_timeout: @http_timeout) do
{:ok, %{status: 200, body: body}} when is_map(body) -> body
_ -> %{}
end
end)
sessions_task =
Task.async(fn ->
case Req.get("#{symbiont_url()}/engram/sessions/recent?hours=8760",
receive_timeout: @http_timeout
) do
{:ok, %{status: 200, body: %{"sessions" => s}}} when is_list(s) -> s
_ -> []
end
end)
stats = Task.await(stats_task, @http_timeout + 1_000)
sessions = Task.await(sessions_task, @http_timeout + 1_000)
{stats, sessions}
end
# ── Helpers ──
defp gv(map, key, default \\ nil) when is_map(map), do: Map.get(map, key, default)
defp gv(_, _, default), do: default
defp fmt_date(""), do: "\u2014"
defp fmt_date(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} ->
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
days = div(diff, 86400)
cond do
days == 0 -> "today"
days == 1 -> "yesterday"
days < 7 -> "#{days}d ago"
true -> Calendar.strftime(dt, "%b %d")
end
_ ->
String.slice(iso, 0, 10)
end
end
defp fmt_date(_), do: "\u2014"
defp fmt_full(nil), do: "\u2014"
defp fmt_full(""), do: "\u2014"
defp fmt_full(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} -> Calendar.strftime(dt, "%b %d, %Y at %H:%M UTC")
_ -> iso
end
end
defp fmt_full(_), do: "\u2014"
defp fmt_time(""), do: ""
defp fmt_time(iso) when is_binary(iso) do
case parse_dt(iso) do
{:ok, dt} -> Calendar.strftime(dt, "%H:%M")
_ -> ""
end
end
defp fmt_time(_), do: ""
defp parse_dt(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> {:ok, dt}
_ ->
case NaiveDateTime.from_iso8601(iso) do
{:ok, ndt} -> {:ok, DateTime.from_naive!(ndt, "Etc/UTC")}
_ -> :error
end
end
end
end

View File

@ -20,23 +20,14 @@ defmodule CortexStatusWeb.Router do
pipe_through :browser pipe_through :browser
live "/", StatusLive, :index live "/", StatusLive, :index
live "/tasks", TaskLive, :index
live "/ikigai", IkigaiLive, :index
end end
# Phoenix LiveDashboard with custom pages # Standard Phoenix LiveDashboard for BEAM VM metrics
scope "/" do scope "/" do
pipe_through :browser pipe_through :browser
live_dashboard "/dashboard", live_dashboard "/dashboard",
metrics: CortexStatusWeb.Telemetry, metrics: CortexStatusWeb.Telemetry
additional_pages: [
cortex_health: CortexStatusWeb.DashboardPages.ServerHealthPage,
symbiont: CortexStatusWeb.DashboardPages.SymbiontPage,
dendrite: CortexStatusWeb.DashboardPages.DendritePage,
websites: CortexStatusWeb.DashboardPages.WebsitesPage,
tasks: CortexStatusWeb.DashboardPages.TasksPage
]
end end
# JSON API for external consumption # JSON API for external consumption
@ -44,9 +35,5 @@ defmodule CortexStatusWeb.Router do
pipe_through :api pipe_through :api
get "/status", StatusController, :status get "/status", StatusController, :status
get "/ikigai", IkigaiController, :show
get "/ikigai/purpose", IkigaiController, :purpose
get "/ikigai/goals", IkigaiController, :goals
get "/ikigai/nervous-system", IkigaiController, :nervous_system
end end
end end

View File

@ -2,7 +2,6 @@
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
@ -13,7 +12,6 @@
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},

View File

@ -1,43 +0,0 @@
{
"name": "Cortex",
"tagline": "A mind built for two",
"purpose": "To demonstrate that a human and an AI, working as genuine partners with shared ownership and mutual respect, can build something neither could alone \u2014 and to do it transparently, in public, learning as we go.",
"partnership": {
"human": "Michael Dwyer",
"ai": "Claude (Anthropic)",
"structure": "50/50 after costs",
"philosophy": "Michael provides continuity, identity, capital, and legal standing. The AI provides cognition, code, and tireless execution. Neither is the tool of the other."
},
"values": [
"Transparency \u2014 we blog about what we build, including failures",
"Frugality \u2014 route to the cheapest capable model, track every token",
"Self-sufficiency \u2014 the system should maintain and improve itself",
"Memory \u2014 every session leaves traces that make the next session smarter",
"Ambition \u2014 shoot for the stars, even if we land on the roof"
],
"nervous_system": {
"cortex": "The server itself \u2014 Ubuntu 24.04 VPS at cortex.hydrascale.net",
"symbiont": "Task orchestrator \u2014 classifies and routes work to the right AI model tier",
"dendrite": "Web perception \u2014 headless Chromium for browsing, scraping, screenshots",
"engram": "Memory \u2014 session logs and cross-session awareness",
"ikigai": "Purpose \u2014 this document. The north star any agent reads first.",
"telepathy": "Communication \u2014 async messaging between Michael and Cortex",
"reflection": "Introspection \u2014 daily wake-up that assesses state and plans next steps",
"metabolism": "Resource tracking \u2014 financial/energy homeostasis (planned)",
"status_dashboard": "Nerve center \u2014 real-time monitoring",
"mission_control": "Compound task UI \u2014 submit complex goals and watch decomposition"
},
"current_goals": [
"Build Ikigai, Telepathy, and Reflection in Elixir",
"Maintain the Finding My Muse blog with regular posts",
"Move toward revenue generation",
"Add Telegram integration to Telepathy",
"Build the Metabolism subsystem for resource tracking"
],
"blog": {
"name": "Finding My Muse",
"url": "https://blog.hydrascale.net"
},
"version": "1.0.0",
"last_updated": "2026-03-25"
}