● Zion Boggan zionboggan.com ↗

Initial import

ba06350   Zion Boggan committed on Jun 27, 2026 (1 month ago)
.gitignore +15 -0
@@ -0,0 +1,15 @@
+node_modules/
+.env
+.env.*
+!.env.example
+.venv/
+venv/
+__pycache__/
+*.pyc
+dist/
+build/
+.next/
+out/
+*.log
+_logs/
+.DS_Store
CODEX_CHAT_FIX_TASK.md +34 -0
@@ -0,0 +1,34 @@
+# TASK (Codex): make Darwin chat full-bleed like Odysseus
+
+File to edit: `/shared/projects/darwin/face/src/app/page.tsx`
+
+## Problem
+Zion's complaint: the chat "stays boxed and squared up" and does not fill the screen,
+while the older Odysseus cockpit lets chat fill the width. The chat component (AskBox) is
+already fluid (`w-full`). The boxing comes from the DESKTOP surface container in page.tsx:
+1. A wrapper caps the whole surface at `max-w-screen-xl` and centers it (`mx-auto`).
+2. A grid wedges the chat (AskBox, the center `1fr` column) between a 320px AgentTree pane
+ and a 320px HitsInbox pane, so chat only gets the squeezed leftover middle strip.
+
+## Make EXACTLY these changes (layout only - do NOT touch colors, fonts, or logic)
+1. Find the DesktopSurface wrapper `<div className="max-w-screen-xl mx-auto">` (around line 324).
+ Change its className: remove `max-w-screen-xl mx-auto`, replace with `w-full`.
+ (Keep any other classes on that div intact.)
+
+2. Find the grid `<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr_320px] gap-5 min-h-[700px]">`
+ (around line 334). Change the large-screen template:
+ `lg:grid-cols-[320px_1fr_320px]` -> `lg:grid-cols-[280px_minmax(0,1fr)_280px]`
+ The `minmax(0,1fr)` is important so the center chat column takes all remaining width and is
+ never squeezed by its content. Keep `grid-cols-1` (mobile) and gap/min-h as-is.
+
+3. In AskBox (the answer/message rendering, `src/components/surface/AskBox.tsx`): ensure the
+ AI/assistant answer block fills the width (`w-full` / `max-w-full`). Mirror Odysseus's pattern:
+ assistant/agent messages 100% width; only the USER's own message bubble is capped (~90%,
+ right-aligned with `ml-auto`). If the assistant block is already `w-full`, leave it; only add the
+ user-bubble cap if user messages are currently full-width too. Do not add fixed pixel max-widths
+ to assistant messages.
+
+## Constraints
+- Layout/width only. No color/font/spacing redesign. No new dependencies.
+- Preserve all existing functionality, props, and the mobile single-column path (around line 400) untouched.
+- After editing, print the exact unified diff of every change you made.
CockpitChat.PNG +0 -0
Binary file not shown
CockpitChat2.PNG +0 -0
Binary file not shown
_ops/AUTHENTIK_DOMAIN_PLAN.md +60 -0
@@ -0,0 +1,60 @@
+# Darwin → REDACTED-HOST (Authentik SSO) - execution plan
+
+Verified-safe plan from edge recon (2026-06-24). COEXISTS with Odysseus chat.lab - NOT the teardown.
+Execute AFTER the workspace build lands (Face must proxy backends same-origin first). Tailscale gives
+on-the-go access NOW in the meantime (phone on tailnet → REDACTED-IP:3000 via CT215 subnet route).
+
+## Already done for us (zero work)
+- **DNS:** Technitium on CT244:53 is a true wildcard - `REDACTED-HOST` already → REDACTED-IP.
+ (NOT AdGuard/CT201, which NXDOMAINs `*.lab`.)
+- **TLS:** wildcard `*.lab.zionboggan.com` cert via Cloudflare DNS-01 ACME (resolver `cloudflare`). Covers it.
+- **WS at the edge:** Traefik v3 proxies WebSockets transparently (chat.lab/Odysseus proves it, no WS config).
+
+## The catch (do FIRST, on the Face / CT252)
+Face uses build-time `NEXT_PUBLIC_*` → raw LAN IPs (`http://REDACTED-IP:9890`, `ws://REDACTED-IP:9893`,
++ term :9894). Behind https that's mixed-content + not remote-reachable + bypasses SSO. FIX = Face proxies
+same-origin so the browser only ever talks to `darwin.lab`:
+- Add `next.config.mjs` `rewrites()`: `/cortex/* → :9890`, `/deck/* → REDACTED-IP:9893`, `/term/* → REDACTED-IP:9894`.
+- Build with relative `NEXT_PUBLIC_CORTEX_URL=/cortex`; for WS use `wss://darwin.lab.../deck/stream` etc.
+- **WS-over-next-rewrite is not guaranteed.** If WS misbehaves → OPTION B: give Deck+Term their own
+ SSO-gated subdomains (`deck.lab`→CT215:9893, `term.lab`→CT215:9894); Traefik passes WS fine. Cortex stays
+ behind the Face rewrite (same-box HTTP).
+- Coordinate this with the workspace build (it adds the term component) - do it as the workspace finalize.
+
+## Edge change (CT244, LAST) - additive, hot-reloaded
+`cp /opt/edge/traefik/dynamic/services.yml{,.bak.pre-darwin.TIMESTAMP}` then append, inside the existing
+`routers:` and `services:` maps (mirror the `dashboard` SSO router + `odysseus` backend):
+```yaml
+# router (in routers:)
+darwin:
+ rule: "Host(`REDACTED-HOST`)"
+ entrypoints: [websecure]
+ tls: { certResolver: cloudflare }
+ service: darwin
+ middlewares: [ sso-auth@file ]
+# service (in services:)
+darwin:
+ loadBalancer:
+ servers: [ { url: "http://REDACTED-IP:3000" } ]
+```
+(+ deck/term routers if Option B.) Save → hot-reload. **YAML-FRAGILE: a parse error makes Traefik drop the
+WHOLE file → 502s jellyfin/dashboard/chat.lab/etc. Back up, append carefully inside the existing maps, watch
+`docker logs traefik` for clean reload.**
+
+## Authentik (CT246, UI at https://REDACTED-HOST, akadmin) - mirror "Master Dashboard"
+1. Proxy Provider `Darwin forwardAuth`: mode `forward_single`, `external_host=https://REDACTED-HOST`,
+ authflow `default-provider-authorization-implicit-consent`.
+2. Application `Darwin` → that provider.
+3. **Assign the provider to the authentik Embedded Outpost** (forwardAuth → CT246:9000). Required or it errors.
+ (Try the Authentik REST API with an akadmin token to automate; else it's a ~2-min manual click for Zion.)
+
+## Verify
+`curl -I https://REDACTED-HOST` → 302 to auth; browser login flow; cortex/deck/term work same-origin.
+
+## Rollback
+Remove the `darwin` router+service blocks (or restore the .bak) → hot-reload, darwin.lab stops routing, zero
+impact elsewhere. Direct `:3000` stays LAN-reachable. Authentik app/provider harmless to leave.
+
+## Key paths
+- Edge route: `/opt/edge/traefik/dynamic/services.yml` (CT244) · SSO mw def: `dynamic/sso-gating.yml` (reference only)
+- Face: `/shared/projects/darwin/face/next.config.mjs` (+ NEXT_PUBLIC_* build vars)
_ops/DESIGN_LOCK.md +41 -0
@@ -0,0 +1,41 @@
+# DARWIN - DESIGN LOCK (binding; every front-end agent MUST obey)
+
+Zion's explicit, repeated directive: **use the reference aesthetic we chose. NEVER revert to generic
+"Claude base design" / AI-SaaS-template / admin-dashboard defaults.** If it looks like a stock
+shadcn/Material starter, it has FAILED. Study the references before writing a line of UI.
+
+## Study FIRST (not optional)
+- Reference images: `/shared/projects/Claud-Design-Revamp/` - monolithstudio, wildyriftian, donprod
+ (bold brutalist type, near-black, classical/organic hero, motion, editorial whitespace).
+- Motion + tokens: `/shared/projects/darwin/_ops/motion-system.md`.
+- The existing Darwin hero (the big "DARWIN" wordmark + organism) is the established look - extend it,
+ do not replace it with something blander.
+
+## BANNED (these = generic; do not produce them)
+- Default rounded-2xl + drop-shadow "feature cards" in a 3-up grid.
+- The "centered hero headline + subtitle + two pill buttons + 3 cards" SaaS template.
+- Purple/indigo gradient AI-startup hero. (Our accent is teal, used sparingly.)
+- Light mode. Default system font stacks. Generic admin-dashboard chrome/sidebars.
+- Emoji used as UI icons. Bootstrap/Material/default-shadcn visual signatures.
+- Soft, safe, "corporate friendly" spacing and type. Timid.
+
+## REQUIRED (the Darwin look)
+- **Canvas:** near-black `#050607`. Deep, confident, cinematic.
+- **Accent:** ONE bioluminescent teal `#16e0c8`, used sparingly (glow, key data, focus). Restraint.
+- **Type:** brutalist, monolithic, oversized, confident display (Space Grotesk bold lineage) +
+ clean mono (JetBrains Mono) for data/paths. Big size contrast. Editorial whitespace.
+- **Motion:** easeOutExpo `cubic-bezier(0.16,1,0.3,1)` dialect everywhere; Lenis smooth scroll;
+ low-amplitude restraint; silk, never janky.
+- **Soul:** the living organism (data-as-creature, health=anatomy). Tension between cold brutalist
+ geometry and a warm living form IS the signature. Awwwards calibre, not template calibre.
+- **Mobile:** must stay ON-BRAND - brutalist editorial that reflows, NOT a generic mobile card stack.
+ Bold type, near-black, teal accent, real motion - just thumb-reachable and single-column/tabbed.
+
+## DESIGN GATE (before any UI task is "done")
+1. Put a screenshot next to the reference images. If it reads as "generic AI SaaS / dashboard," REDO.
+2. Is the type bold/brutalist and oversized, or timid/default? Must be the former.
+3. Is the canvas deep near-black with ONE restrained teal accent, or a busy/light/gradient mess?
+4. Does it feel alive (organism, motion) or static/corporate?
+5. Mobile: on-brand and usable, or a degraded generic stack?
+
+If unsure, it's too generic - push it bolder, toward the references.
_ops/motion-system.md +53 -0
@@ -0,0 +1,53 @@
+# Darwin - Motion System (buildable primitives)
+
+Distilled from a confirmed teardown of monolithstudio.com, wildyriftian.com, donprod.uk,
+thirdweb.com, paraform.com. **Key law:** premium feel = ONE signature move + a disciplined easing
+dialect, not WebGL everywhere (only 1 of 5 refs used real 3D, pre-baked at 30fps). Restraint wins.
+
+**Stack:** React (Next.js) + GSAP (ScrollTrigger, Flip, SplitText) + Lenis + React-Three-Fiber.
+
+## Easing dialect (use these two everywhere)
+```css
+--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* entrances/reveals - GSAP "expo.out" */
+--ease-inout-expo: cubic-bezier(0.87, 0, 0.13, 1); /* symmetric flips - GSAP "power4.inOut"*/
+```
+
+## Primitives
+1. **lenis-scroll-base** - global. `new Lenis({lerp:0.1, smoothWheel:true})`; drive GSAP:
+ `lenis.on('scroll', ScrollTrigger.update)`, `gsap.ticker.add(t=>lenis.raf(t*1000))`,
+ `gsap.ticker.lagSmoothing(0)`. Substrate for every ScrollTrigger primitive.
+2. **fade-rise-stagger** - the universal reveal. `opacity:0→1, y:24→0`, `dur 0.6, ease expo.out,
+ stagger 0.09`, ScrollTrigger `start:"top 85%", once:true`. Low amplitude. Default for sections.
+3. **mask-reveal-up** - headlines. SplitText by lines, each line in `overflow:hidden`,
+ `yPercent:110→0`, `dur 0.9, ease expo.out, stagger 0.08`. On-load for hero, ScrollTrigger else.
+4. **clip-wipe-reveal** - images/feature blocks. `clip-path: inset(50%)→inset(0)`, `dur 1.0-1.4,
+ ease expo.out`. Center-out for hero, directional for sequences.
+5. **evolving-organism** (THE SIGNATURE) - R3F. Hero creature whose cells = real data sources.
+ - Load: speck → cluster → organism, ~1.2s expo.out (generative self-assembly).
+ - Scroll: ScrollTrigger `pin:true, scrub:1` over ~150vh maps progress → growth/specialization.
+ - Focus ask-field: organism collapses inward → folds into the cursor/input.
+ - Ask: synapses fire along queried engine-paths; answer assembles from glowing trails.
+ - Health=anatomy: fresh source → bioluminescent/animated; stale → limb greys + withers.
+ - **Perf:** realtime R3F with HDRI envMap is fine; a pre-baked image-sequence/`<video>` scrubbed
+ by ScrollTrigger is the faithful cheaper fallback (Monolith did this; 3060 is currently down so
+ DO NOT hard-depend on GPU compute - bake or run light).
+6. **magnetic-hover** - CTAs/nav. within radius `gsap.to(el,{x:dx*0.3,y:dy*0.3,dur:0.4,ease:"power3.out"})`,
+ reset on leave with `back.out(1.5)`. Pair with an arrow that nudges.
+7. **invert-cursor** - custom cursor div, `mix-blend-mode:difference`, white fill, lerp follow 0.15;
+ swap a label ("ask"/"open") + scale on hoverable targets.
+8. **gradient-aurora** - large absolute blob, stacked radial gradients, `filter:blur(120px)`. For
+ perf, **bake to a static gradient image** (thirdweb #703) instead of runtime blur. Accent word:
+ `bg-clip-text text-transparent`.
+9. **logo-marquee / source-marquee** - infinite `translateX` on a duplicated track, edge
+ `mask-image: linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent)`. Use for the live
+ "sources feeding Darwin" strip. Slow on hover.
+
+## Build order (80% of perceived polish, fast)
+`lenis-scroll-base` → `fade-rise-stagger` → `mask-reveal-up` → `evolving-organism` (signature) →
+`magnetic-hover`. The easeOutExpo dialect + low-amplitude restraint do the rest.
+
+## Palette / type
+- Canvas near-black `#050607`. One bioluminescent accent (deep-sea teal) `~#16e0c8`, used sparingly.
+- Brutalist monolithic display type holding the corners; editorial whitespace.
+- Tension between cold brutalist geometry and a warm living organism = the signature aesthetic.
+- Reference images live in `/shared/projects/Claud-Design-Revamp/` - study before building.
cortex/CONTRACTS.md +118 -0
@@ -0,0 +1,118 @@
+# Darwin Cortex - Gateway Contract (binding)
+
+The keystone. One service, one verb: **`ask`**. Fans out to every memory engine in parallel,
+reconciles by the source-of-truth hierarchy, synthesizes one answer with clickable receipts, and
+is itself exposed as the single MCP every agent calls.
+
+**Runtime:** Python 3.11 · FastAPI · `httpx` (async) · `mcp` SSE client · `pydantic` v2.
+**Lives on:** Darwin CT (REDACTED-IP). During dev may run on CT215.
+**Ports:** HTTP API `:9890` · MCP SSE shim `:9891`.
+
+---
+
+## 1. Engines (fan-out targets) - all already live on REDACTED-IP
+
+| key | transport | primary calls | answers |
+|---|---|---|---|
+| `rag` | SSE `:9876` | `homelab_search`, `homelab_context` | content / what / where / why |
+| `cbm` | SSE `:9877` | `search_graph`, `get_architecture` | code structure, blast radius |
+| `serena` | SSE `:9878` | `find_symbol`, `find_referencing_symbols` | live symbol truth |
+| `memgraph` | SSE `:9879` | `query_entities`, `query_relations` | decisions, ownership, handoffs |
+| `inbox` | HTTP (Memos API) | list/search memos | Zion's raw captures |
+
+Each engine is wrapped in an **adapter** with: a hard timeout (default 4000ms), a normalizer that
+maps its native result into the common `Hit` shape, and **graceful degradation** - a dead engine
+returns `{engine, status:"down", hits:[]}`, never throws. The set of up/down engines is returned to
+the UI verbatim (it drives the organism's health-as-anatomy).
+
+---
+
+## 2. API
+
+### `POST /ask`
+```jsonc
+// request
+{
+ "query": "string",
+ "mode": "both", // "chat" | "search" | "both" (default "both")
+ "scope": ["rag","cbm"], // optional engine subset; default all
+ "k": 12, // max hits per engine
+ "synthesize": true // run LLM synthesis for the chat answer
+}
+```
+```jsonc
+// response
+{
+ "answer": "string|null", // prose synthesis (null if mode=search or synth unavailable)
+ "receipts": [ // every claim in `answer` cites one of these
+ {"id":"r1","source":"rag","path":"...","last_seen":"2026-06-24T00:04:00Z","confidence":0.92,"snippet":"..."}
+ ],
+ "hits": [ // raw ranked results (the "search" half of the surface)
+ {"engine":"rag","title":"...","path":"...","snippet":"...","score":0.81,
+ "last_seen":"...","confidence":0.92}
+ ],
+ "conflicts": [ // where engines disagreed + who won and why
+ {"claim":"Hermes status","winner":"live-infra","losers":["agent-memory"],"reason":"memory stale 40d"}
+ ],
+ "engines": [ {"engine":"rag","status":"up","ms":312}, {"engine":"cbm","status":"down"} ],
+ "took_ms": 480
+}
+```
+
+### `GET /health`
+Per-engine liveness + freshness (drives organism anatomy):
+```jsonc
+{"engines":[{"engine":"rag","status":"up","last_ingest":"2026-06-24T00:04:00Z","staleness_h":0.4}], "ok":true}
+```
+
+---
+
+## 3. Ranking & reconciliation (Zion's ruling, recency-gated)
+
+Authority when sources conflict:
+```
+live infra state > recent files > agent memory > older notes
+```
+Implement as a **per-hit score**:
+```
+score = w_authority(source) * recency_decay(last_seen) * confidence * relevance
+```
+- `w_authority`: live-infra 1.0, recent-file 0.8, agent-memory 0.6, older-note 0.4
+- `recency_decay`: `exp(-age_days / HALF_LIFE)`, HALF_LIFE default 30d. **This is the Darwin valve** -
+ it pushes stale agent-memory *below* fresher notes automatically (the exact fix for the Hermes lie).
+- A "recent file" is `mtime < 30d`; older → demoted to the notes tier.
+- When two hits assert the same fact and disagree, the higher `score` wins; the loss is recorded in
+ `conflicts[]` with a human reason. Never silently drop the loser.
+
+---
+
+## 4. Synthesis routing (cost-aware; 3060 currently DOWN)
+
+| question complexity | synthesizer | note |
+|---|---|---|
+| cheap / factual | qwen3:8b on 3060 (`http://REDACTED-IP:11434/api/chat`, `think:false`) | **fallback while 3060 down → deterministic ranked-merge (no LLM)** |
+| hard / multi-source | Opus (Claude API) | gated behind a flag; no paid calls until enabled |
+
+v1 ships with **deterministic ranked-merge always working** (top hits → templated answer + receipts),
+LLM synthesis pluggable behind `synthesize` + an available-backend check. Never hard-depend on a
+backend that may be asleep.
+
+---
+
+## 5. MCP exposure - the unified verb
+
+Expose one tool, `darwin_ask(query, mode?, scope?)`, over **stdio + SSE `:9891`**, so Zion's agents
+(Codex CT241, GLM/OpenCode CT247, Cockpit, this Claude) replace four memory tools with one. Register
+in each agent's MCP config as `darwin`. The four underlying engines stay available for power use, but
+`darwin_ask` is the front door.
+
+---
+
+## 6. Non-negotiables
+- No claim in `answer` without a matching `receipts[]` entry. Provenance or it doesn't ship.
+- Every engine failure degrades gracefully and is surfaced, never hidden.
+- State/cache on **local ext4** (`/var/lib/darwin` or `/var/tmp/darwin`), NEVER on the CIFS share
+ (CIFS → `database is locked`).
+- Read-only against the engines. The Cortex never writes to RAG/cbm/Serena/memgraph.
+- Self-test: `tests/test_reconcile.py` must prove a 40-day-stale agent-memory "Hermes active" loses
+ to live infra "Hermes stopped". That test is the regression guard for the bug that started Darwin.
cortex/README.md +96 -0
@@ -0,0 +1,96 @@
+# Darwin Cortex Gateway
+
+Unified second-brain gateway. One verb: `ask`. Fans out to all memory engines in parallel, reconciles by source-of-truth hierarchy with recency decay, returns ranked hits + receipts + conflict log.
+
+## How to run
+
+### Development (CT215 local ext4)
+
+```bash
+cd /var/tmp/darwin-dev/cortex
+pip install -r requirements.txt
+uvicorn main:app --host 0.0.0.0 --port 9890 --reload
+```
+
+State/cache never touches CIFS. The app is stateless; no files written at runtime.
+
+### Tests
+
+```bash
+cd /var/tmp/darwin-dev/cortex
+python -m pytest tests/ -v
+```
+
+Both test suites must be green before any deploy:
+- `tests/test_reconcile.py` - the Hermes regression guard (13 tests)
+- `tests/test_smoke.py` - graceful degradation + ranking integration (6 tests)
+
+### MCP stdio server (agents)
+
+```bash
+python /opt/darwin/cortex/mcp_server.py
+```
+
+Add to an agent's MCP config:
+```json
+{
+ "mcpServers": {
+ "darwin": {
+ "command": "python",
+ "args": ["/opt/darwin/cortex/mcp_server.py"]
+ }
+ }
+}
+```
+
+### MCP SSE shim (Cockpit / remote agents)
+
+```bash
+pip install mcp-proxy
+mcp-proxy --port 9891 -- python /opt/darwin/cortex/mcp_server.py
+```
+
+Register as: `http://REDACTED-IP:9891/sse`
+
+## Deploy to Darwin CT (REDACTED-IP)
+
+1. Provision CT252 with Ubuntu 22.04, Python 3.11, pip.
+2. Copy cortex tree: `scp -r /shared/projects/darwin/cortex root@REDACTED-IP:/opt/darwin/cortex`
+3. Install deps: `pip install -r /opt/darwin/cortex/requirements.txt`
+4. Set env vars (in `/opt/darwin/.env` or systemd unit):
+ - `MEMOS_ACCESS_TOKEN=<memos-api-token>`
+ - `MEMOS_BASE_URL=https://REDACTED-HOST` (or CT244 direct `http://192.168.1.XX:5230`)
+ - `DARWIN_QWEN_URL=http://REDACTED-IP:11434/api/chat` (when 3060 is up)
+ - `DARWIN_ENABLE_OPUS=false` (set true + DARWIN_CLAUDE_API_KEY when ready)
+5. Point engine URLs at CT215 (where the four engines live):
+ All engine SSE URLs in the adapters default to `127.0.0.1` - override if Darwin CT is separate:
+ - `RAG_URL=http://REDACTED-IP:9876/sse`
+ - `CBM_URL=http://REDACTED-IP:9877/sse`
+ - `SERENA_URL=http://REDACTED-IP:9878/sse`
+ - `MEMGRAPH_URL=http://REDACTED-IP:9879/sse`
+ (Or run behind mcp-proxy SSE bridges on CT215 and point there.)
+6. Create systemd unit:
+ ```ini
+ [Unit]
+ Description=Darwin Cortex Gateway
+ After=network.target
+
+ [Service]
+ WorkingDirectory=/opt/darwin/cortex
+ ExecStart=uvicorn main:app --host 0.0.0.0 --port 9890
+ Restart=always
+ EnvironmentFile=/opt/darwin/.env
+
+ [Install]
+ WantedBy=multi-user.target
+ ```
+7. `systemctl enable --now darwin-cortex`
+
+## Architecture notes
+
+- `/ask` fans out to up to 5 engines in parallel, each with a 4s hard timeout.
+- Ranking: `score = w_authority * recency_decay * confidence * relevance`
+ - `recency_decay = exp(-age_days/30)` - the Darwin valve that makes stale agent-memory lose to fresh live-infra facts.
+- LLM synthesis: tries qwen3:8b on 3060 first; falls back to deterministic ranked-merge (always works). Opus gated behind env flag.
+- MCP tool `darwin_ask` wraps `/ask` for agent consumption. SSE shim on :9891 via mcp-proxy.
+- All state on local ext4 (`/var/tmp/darwin`). Never on CIFS.
cortex/adapters/__init__.py +1 -0
@@ -0,0 +1 @@
+# Darwin Cortex engine adapters
cortex/adapters/base.py +36 -0
@@ -0,0 +1,36 @@
+"""Base adapter interface + graceful-degradation wrapper."""
+from __future__ import annotations
+
+import asyncio
+import time
+from abc import ABC, abstractmethod
+from typing import Any
+
+from models import EngineStatus, Hit
+
+TIMEOUT_S: float = 6.0
+
+
+class BaseAdapter(ABC):
+ engine: str = ""
+ authority_tier: str = "older-note"
+
+ @abstractmethod
+ async def _fetch(self, query: str, k: int) -> list[Hit]:
+ """Override in subclass - may raise freely."""
+ ...
+
+ async def query(self, query: str, k: int = 12) -> tuple[list[Hit], EngineStatus]:
+ """Public entry point - always returns (hits, status), never raises."""
+ t0 = time.monotonic()
+ try:
+ hits = await asyncio.wait_for(self._fetch(query, k), timeout=TIMEOUT_S)
+ # Apply default authority tier only when the adapter has not set one
+ for h in hits:
+ if h.authority_tier == "older-note" and self.authority_tier != "older-note":
+ h.authority_tier = self.authority_tier
+ ms = int((time.monotonic() - t0) * 1000)
+ return hits, EngineStatus(engine=self.engine, status="up", ms=ms)
+ except Exception:
+ ms = int((time.monotonic() - t0) * 1000)
+ return [], EngineStatus(engine=self.engine, status="down", ms=ms)
cortex/adapters/cbm.py +171 -0
@@ -0,0 +1,171 @@
+"""cbm-homelab adapter - SSE :9877.
+
+Calls search_graph across indexed projects. Authority tier: recent-file.
+
+CBM's search_graph requires a project parameter. We discover the project list
+by calling list_projects first (cached after first success), then fan out
+search_graph calls to the most relevant projects in parallel.
+
+If list_projects is unavailable we fall back to the project list embedded
+in the "project not found" error that search_graph returns without a project arg.
+"""
+from __future__ import annotations
+import asyncio
+import json
+import os
+
+from models import Hit
+from adapters.base import BaseAdapter
+from adapters.mcp_base import call_mcp_tool
+
+CBM_URL = os.environ.get("CBM_URL", "http://REDACTED-IP:9877/sse")
+
+# Cache so we only call list_projects once per process lifetime
+_PROJECT_CACHE: list[str] | None = None
+
+# How many projects to fan out searches to (cap to avoid thundering herd)
+MAX_PROJECTS = 6
+
+
+async def _get_projects() -> list[str]:
+ """Return list of available CBM project names, cached."""
+ global _PROJECT_CACHE
+ if _PROJECT_CACHE is not None:
+ return _PROJECT_CACHE
+
+ blocks = await call_mcp_tool(CBM_URL, "list_projects", {}, timeout=3.5)
+ for block in blocks:
+ projects = block.get("projects") if isinstance(block, dict) else None
+ if isinstance(projects, list):
+ _PROJECT_CACHE = [str(p) for p in projects]
+ return _PROJECT_CACHE
+
+ # Fallback: fire search_graph without project to get the error with available_projects
+ err_blocks = await call_mcp_tool(CBM_URL, "search_graph", {"query": "status", "limit": 1}, timeout=3.5)
+ for block in err_blocks:
+ # block may be {"text": "{\"error\":...,\"available_projects\":[...]}"}
+ raw = block.get("text", "") if isinstance(block, dict) else ""
+ try:
+ parsed = json.loads(raw)
+ avail = parsed.get("available_projects") or []
+ if avail:
+ _PROJECT_CACHE = [str(p) for p in avail]
+ return _PROJECT_CACHE
+ except Exception:
+ pass
+
+ _PROJECT_CACHE = []
+ return _PROJECT_CACHE
+
+
+def _rank_projects(projects: list[str], query: str) -> list[str]:
+ """Pick the most query-relevant projects to search (heuristic)."""
+ query_lower = query.lower()
+ query_words = set(query_lower.split())
+
+ def relevance(proj: str) -> int:
+ p = proj.lower()
+ score = 0
+ for word in query_words:
+ if word in p:
+ score += 3
+ # Prefer human-coded projects (hermes, treetrace, jobforge, etc.)
+ if any(k in p for k in ("hermes", "treetrace", "jobforge", "oversight",
+ "darwin", "personal", "homelab", "portfolio")):
+ score += 2
+ # Penalise archive/bounty/staging noise
+ if any(k in p for k in ("archive", "bounty", "staging", "triage",
+ "mirror", "cache", "plugins")):
+ score -= 1
+ return score
+
+ ranked = sorted(projects, key=relevance, reverse=True)
+ return ranked[:MAX_PROJECTS]
+
+
+def _parse_cbm_block(block: dict) -> list[Hit]:
+ """Parse one content block from search_graph into Hit objects."""
+ # block is the JSON-parsed result: {"total":N, "results":[...], ...}
+ results = block.get("results") or block.get("nodes") or []
+ hits = []
+ if isinstance(results, list):
+ for r in results:
+ if not isinstance(r, dict):
+ continue
+ # Skip empty / error results
+ name = r.get("name") or r.get("title") or r.get("qualified_name", "")
+ if not name:
+ continue
+ file_path = r.get("file") or r.get("path") or r.get("file_path", "")
+ snippet = (r.get("summary") or r.get("snippet") or
+ r.get("signature") or "")[:300]
+ # raw_score: cbm uses bm25 (lower = better). Normalise to 0..1.
+ raw = r.get("score") or r.get("relevance") or r.get("rank") or 0
+ try:
+ score = min(1.0, max(0.0, -float(raw) / 10.0)) if float(raw) < 0 else float(raw)
+ except Exception:
+ score = 0.5
+ hits.append(Hit(
+ engine="cbm",
+ title=str(name),
+ path=str(file_path),
+ snippet=str(snippet),
+ score=score,
+ last_seen=None,
+ confidence=0.8,
+ authority_tier="recent-file",
+ ))
+ elif isinstance(block, dict) and "text" in block:
+ # Plain-text fallback (error messages, etc.) - skip errors
+ text = str(block["text"])
+ if '"error"' not in text:
+ hits.append(Hit(
+ engine="cbm",
+ title="code graph result",
+ snippet=text[:300],
+ score=0.5,
+ confidence=0.7,
+ authority_tier="recent-file",
+ ))
+ return hits
+
+
+class CbmAdapter(BaseAdapter):
+ engine = "cbm"
+ authority_tier = "recent-file"
+
+ async def _fetch(self, query: str, k: int) -> list[Hit]:
+ projects = await _get_projects()
+ if not projects:
+ return []
+
+ selected = _rank_projects(projects, query)
+ if not selected:
+ return []
+
+ # Fan out parallel searches across selected projects
+ tasks = [
+ asyncio.create_task(
+ call_mcp_tool(CBM_URL, "search_graph",
+ {"project": proj, "query": query, "limit": k},
+ timeout=3.0)
+ )
+ for proj in selected
+ ]
+ all_blocks_list = await asyncio.gather(*tasks, return_exceptions=True)
+
+ all_hits: list[Hit] = []
+ seen: set[str] = set()
+ for blocks in all_blocks_list:
+ if isinstance(blocks, Exception) or not blocks:
+ continue
+ for block in blocks:
+ for hit in _parse_cbm_block(block):
+ key = hit.path or hit.title
+ if key not in seen:
+ seen.add(key)
+ all_hits.append(hit)
+
+ # Sort by score descending
+ all_hits.sort(key=lambda h: h.score, reverse=True)
+ return all_hits[:k]
cortex/adapters/inbox.py +73 -0
@@ -0,0 +1,73 @@
+"""Inbox adapter - Memos REST API on CT244.
+
+Memos runs on CT244 at internal port 5230 (docker bind 127.0.0.1:5230 on CT244).
+From CT215 we reach it via its public URL: https://REDACTED-HOST
+API endpoint: GET /api/v1/memos (Memos v0.29.x REST API)
+
+The API requires auth. We read the access token from env MEMOS_ACCESS_TOKEN.
+If unset or unreachable we degrade to status:"down".
+
+Authority tier: agent-memory (user captures, may be stale).
+"""
+from __future__ import annotations
+
+import os
+from datetime import datetime, timezone
+
+import httpx
+
+from models import Hit
+from adapters.base import BaseAdapter
+
+MEMOS_BASE = os.environ.get("MEMOS_BASE_URL", "https://REDACTED-HOST")
+MEMOS_TOKEN = os.environ.get("MEMOS_ACCESS_TOKEN", "")
+
+
+def _parse_memos(data: list[dict]) -> list[Hit]:
+ hits = []
+ for m in data:
+ if not isinstance(m, dict):
+ continue
+ dt = None
+ for field in ("displayTime", "createTime", "updateTime"):
+ raw = m.get(field)
+ if raw:
+ try:
+ dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
+ except Exception:
+ pass
+ break
+ content = m.get("content") or ""
+ hits.append(Hit(
+ engine="inbox",
+ title=content[:60].replace("\n", " "),
+ path=m.get("name", ""),
+ snippet=content[:300],
+ score=0.5,
+ last_seen=dt,
+ confidence=0.65,
+ authority_tier="agent-memory",
+ ))
+ return hits
+
+
+class InboxAdapter(BaseAdapter):
+ engine = "inbox"
+ authority_tier = "agent-memory"
+
+ async def _fetch(self, query: str, k: int) -> list[Hit]:
+ if not MEMOS_TOKEN:
+ raise RuntimeError("MEMOS_ACCESS_TOKEN not set")
+
+ headers = {"Authorization": f"Bearer {MEMOS_TOKEN}"}
+ params = {"pageSize": k, "filter": f'content.contains("{query}")'}
+ async with httpx.AsyncClient(timeout=3.5, verify=False) as client:
+ resp = await client.get(
+ f"{MEMOS_BASE}/api/v1/memos",
+ headers=headers,
+ params=params,
+ )
+ resp.raise_for_status()
+ body = resp.json()
+ memos = body if isinstance(body, list) else body.get("memos", [])
+ return _parse_memos(memos)[:k]
cortex/adapters/mcp_base.py +49 -0
@@ -0,0 +1,49 @@
+"""Thin MCP SSE client helper.
+
+Uses the `mcp` Python SDK's sse_client transport to connect to an SSE MCP server,
+call one tool, and return the raw text result. Falls back gracefully on any failure.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+from contextlib import asynccontextmanager
+from typing import Any, AsyncGenerator
+
+import httpx
+from mcp.client.sse import sse_client
+from mcp import ClientSession
+
+
+async def call_mcp_tool(
+ base_url: str,
+ tool_name: str,
+ arguments: dict[str, Any],
+ timeout: float = 3.5,
+) -> list[Any]:
+ """Call an MCP tool over SSE and return parsed content blocks.
+
+ Each content block's text is JSON-parsed when possible. The result may be:
+ - A dict (e.g. {"total": N, "results": [...]} from CBM search_graph)
+ - A list (e.g. [...entities...] from memory-graph query_entities)
+ - A dict with "text" key (plain-text fallback when JSON parse fails)
+
+ Returns empty list on any failure (timeout, connection error, parse error).
+ """
+ try:
+ async with asyncio.timeout(timeout):
+ async with sse_client(base_url) as (read, write):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.call_tool(tool_name, arguments=arguments)
+ # result.content is a list of content objects
+ out: list[Any] = []
+ for block in (result.content or []):
+ if hasattr(block, "text"):
+ try:
+ out.append(json.loads(block.text))
+ except (json.JSONDecodeError, TypeError):
+ out.append({"text": block.text})
+ return out
+ except Exception:
+ return []
cortex/adapters/memgraph.py +188 -0
@@ -0,0 +1,188 @@
+"""memory-graph adapter - SSE :9879.
+
+Calls query_entities + query_relations. Authority tier: agent-memory.
+
+RESPONSE SHAPE: The memory-graph MCP server returns raw JSON arrays, not wrapped
+dicts. call_mcp_tool json.loads each block text, so `blocks` is a list where
+each element is the parsed payload - which for query_entities is a list of entity
+dicts directly (not {"entities": [...]}):
+
+ blocks = [[{entity}, {entity}], ...] # one block per content item
+
+We extract entities from this by treating each block that is a list as the
+entity list, and each block that is a dict as a single entity fallback.
+
+For query terms, the server supports:
+ - type= filter by entity type (project, decision, person, etc.)
+ - name= substring match on entity name
+ - q= free-text search (may not be implemented in all versions)
+ Omitting all filters returns all entities (use limit to cap).
+
+Strategy: fetch all entities (limit=k*3) and filter client-side for relevance,
+plus fetch relations for the most relevant entity ids.
+"""
+from __future__ import annotations
+import asyncio
+import os
+
+from datetime import datetime, timezone
+
+from models import Hit
+from adapters.base import BaseAdapter
+from adapters.mcp_base import call_mcp_tool
+
+MEMGRAPH_URL = os.environ.get("MEMGRAPH_URL", "http://REDACTED-IP:9879/sse")
+
+
+def _parse_dt(raw) -> datetime | None:
+ if not raw:
+ return None
+ try:
+ return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
+ except Exception:
+ return None
+
+
+def _entity_to_hit(e: dict) -> Hit | None:
+ """Convert a memory-graph entity dict to a Hit."""
+ if not isinstance(e, dict):
+ return None
+ entity_id = e.get("id", "")
+ name = e.get("name") or entity_id
+ attributes = e.get("attributes") or {}
+ # Build snippet from attributes (most informative fields first)
+ snippet_parts = []
+ for key in ("status", "summary", "description", "lesson", "notes"):
+ val = attributes.get(key)
+ if val:
+ snippet_parts.append(f"{key}: {val}")
+ if not snippet_parts:
+ # Flatten all attributes
+ snippet_parts = [f"{k}: {v}" for k, v in attributes.items()]
+ snippet = " | ".join(snippet_parts)[:400]
+
+ dt = _parse_dt(
+ e.get("updated_at") or e.get("created_at") or e.get("last_seen")
+ )
+ return Hit(
+ engine="memgraph",
+ title=str(name),
+ path=f"memory://{entity_id}",
+ snippet=snippet or str(attributes)[:300],
+ score=0.6,
+ last_seen=dt,
+ confidence=0.75,
+ authority_tier="agent-memory",
+ )
+
+
+def _relation_to_hit(r: dict) -> Hit | None:
+ """Convert a memory-graph relation dict to a Hit."""
+ if not isinstance(r, dict):
+ return None
+ from_id = r.get("from_id", "")
+ to_id = r.get("to_id", "")
+ relation = r.get("relation", "")
+ if not (from_id and to_id and relation):
+ return None
+ snippet = f"{from_id} --[{relation}]--> {to_id}"
+ attrs = r.get("attributes") or {}
+ if attrs:
+ snippet += f" | {attrs}"
+ dt = _parse_dt(r.get("created_at") or r.get("valid_from"))
+ return Hit(
+ engine="memgraph",
+ title=f"{from_id} → {to_id}",
+ path=f"memory://relation/{r.get('id', '')}",
+ snippet=snippet[:400],
+ score=0.5,
+ last_seen=dt,
+ confidence=0.65,
+ authority_tier="agent-memory",
+ )
+
+
+def _extract_items(blocks: list) -> list[dict]:
+ """Extract entity/relation dicts from call_mcp_tool return value.
+
+ Handles both:
+ - blocks = [[{entity}, ...], ...] (list of parsed JSON arrays)
+ - blocks = [{"entities": [{...}]}, ...] (list of parsed dicts with key)
+ - blocks = [{entity}] (flat list of dicts)
+ """
+ items: list[dict] = []
+ for block in blocks:
+ if isinstance(block, list):
+ # Each element of this list is an entity/relation dict
+ items.extend(block)
+ elif isinstance(block, dict):
+ # Could be {"entities": [...]} or {"results": [...]} wrapper
+ inner = block.get("entities") or block.get("results") or block.get("relations")
+ if isinstance(inner, list):
+ items.extend(inner)
+ elif block.get("id"):
+ # Looks like a bare entity dict
+ items.append(block)
+ return items
+
+
+def _score_by_query(entity: dict, query_lower: str) -> float:
+ """Rough relevance score for an entity against the query."""
+ text = " ".join([
+ str(entity.get("name") or ""),
+ str(entity.get("id") or ""),
+ str(entity.get("type") or ""),
+ str(entity.get("attributes") or ""),
+ ]).lower()
+ query_words = query_lower.split()
+ matched = sum(1 for w in query_words if w in text and len(w) > 2)
+ return min(1.0, 0.4 + matched * 0.15)
+
+
+class MemgraphAdapter(BaseAdapter):
+ engine = "memgraph"
+ authority_tier = "agent-memory"
+
+ async def _fetch(self, query: str, k: int) -> list[Hit]:
+ query_lower = query.lower()
+
+ # Fetch a broad set of entities (limit k*4 to allow client-side ranking)
+ entity_task = asyncio.create_task(
+ call_mcp_tool(MEMGRAPH_URL, "query_entities", {"limit": k * 4}, timeout=3.5)
+ )
+ # Also fetch relations (gives temporal/graph context)
+ relation_task = asyncio.create_task(
+ call_mcp_tool(MEMGRAPH_URL, "query_relations", {"limit": k * 2}, timeout=3.5)
+ )
+ entity_blocks, relation_blocks = await asyncio.gather(entity_task, relation_task)
+
+ # Parse entities and score by query relevance
+ entities = _extract_items(entity_blocks)
+ entity_hits: list[Hit] = []
+ for e in entities:
+ hit = _entity_to_hit(e)
+ if hit:
+ hit.score = _score_by_query(e, query_lower)
+ entity_hits.append(hit)
+
+ # Parse relations
+ relations = _extract_items(relation_blocks)
+ relation_hits: list[Hit] = []
+ for r in relations:
+ hit = _relation_to_hit(r)
+ if hit:
+ relation_hits.append(hit)
+
+ # Combine, rank by score, deduplicate
+ all_hits = entity_hits + relation_hits
+ all_hits.sort(key=lambda h: h.score, reverse=True)
+
+ seen: set[str] = set()
+ deduped: list[Hit] = []
+ for h in all_hits:
+ key = h.path or h.title
+ if key not in seen:
+ seen.add(key)
+ deduped.append(h)
+
+ return deduped[:k]
cortex/adapters/rag.py +93 -0
@@ -0,0 +1,93 @@
+"""homelab-rag adapter - queries the Darwin RAG bridge (port 9875) directly.
+
+The RAG bridge is a lightweight HTTP service on CT215 that queries the
+homelab-rag SQLite FTS index directly, bypassing the Node.js MCP SSE shim
+which takes 70-80s per call due to per-call Node.js process spawning + CIFS reads.
+
+Bridge endpoint: GET http://REDACTED-IP:9875/search?q=QUERY&limit=N
+Returns: JSON array in the same shape as homelab_search results.
+
+Authority tier: recent-file (homelab-rag indexes files with mtime).
+"""
+from __future__ import annotations
+import os
+
+from datetime import datetime, timezone
+
+import httpx
+
+from models import Hit
+from adapters.base import BaseAdapter
+
+RAG_BRIDGE_URL = os.environ.get("RAG_BRIDGE_URL", "http://REDACTED-IP:9875")
+RECENT_CUTOFF_DAYS = 30
+
+
+def _parse_dt(val) -> datetime | None:
+ if not val:
+ return None
+ try:
+ if isinstance(val, (int, float)):
+ # mtimeMs is epoch-ms
+ ts = val / 1000.0 if val > 1e10 else float(val)
+ return datetime.fromtimestamp(ts, tz=timezone.utc)
+ return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
+ except Exception:
+ return None
+
+
+def _authority_from_mtime(dt: datetime | None) -> str:
+ if dt is None:
+ return "older-note"
+ age = (datetime.now(timezone.utc) - dt).days
+ return "recent-file" if age <= RECENT_CUTOFF_DAYS else "older-note"
+
+
+def _parse_bridge_results(results: list[dict]) -> list[Hit]:
+ """Convert bridge response (list of result dicts) to Hit objects."""
+ hits = []
+ for r in results:
+ if not isinstance(r, dict):
+ continue
+ mtime = _parse_dt(r.get("mtimeMs") or r.get("mtime") or r.get("last_seen"))
+ snippet = r.get("text") or r.get("snippet") or r.get("content", "")
+ hits.append(Hit(
+ engine="rag",
+ title=r.get("title") or r.get("relPath", "")[:80],
+ path=r.get("path") or r.get("relPath", ""),
+ snippet=str(snippet)[:400],
+ score=float(r.get("score") or 0.5),
+ last_seen=mtime,
+ confidence=0.8,
+ authority_tier=_authority_from_mtime(mtime),
+ ))
+ return hits
+
+
+class RagAdapter(BaseAdapter):
+ engine = "rag"
+ authority_tier = "recent-file" # default; overridden per-hit
+
+ async def _fetch(self, query: str, k: int) -> list[Hit]:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ resp = await client.get(
+ f"{RAG_BRIDGE_URL}/search",
+ params={"q": query, "limit": k},
+ )
+ resp.raise_for_status()
+ results = resp.json()
+
+ if not isinstance(results, list):
+ return []
+
+ hits = _parse_bridge_results(results)
+
+ # deduplicate by path
+ seen: set[str] = set()
+ deduped = []
+ for h in hits:
+ key = h.path or h.snippet[:50]
+ if key not in seen:
+ seen.add(key)
+ deduped.append(h)
+ return deduped[:k]
cortex/adapters/serena.py +57 -0
@@ -0,0 +1,57 @@
+"""Serena adapter - SSE :9878.
+
+Calls find_symbol. Authority tier: live-infra (live LSP, always fresh).
+
+NOTE: Serena requires an active project. Without project activation this will
+return empty results. The adapter degrades gracefully; use the scope filter to
+exclude serena when querying non-code questions.
+"""
+from __future__ import annotations
+import os
+
+from models import Hit
+from adapters.base import BaseAdapter
+from adapters.mcp_base import call_mcp_tool
+
+SERENA_URL = os.environ.get("SERENA_URL", "http://127.0.0.1:9878/sse")
+
+
+def _parse_serena(blocks: list[dict]) -> list[Hit]:
+ hits = []
+ for block in blocks:
+ results = block.get("symbols") or block.get("results") or []
+ if isinstance(results, list):
+ for r in results:
+ if not isinstance(r, dict):
+ continue
+ hits.append(Hit(
+ engine="serena",
+ title=r.get("name") or r.get("symbol", ""),
+ path=r.get("file") or r.get("path", ""),
+ snippet=r.get("signature") or r.get("snippet", "")[:300],
+ score=float(r.get("score") or 0.6),
+ last_seen=None,
+ confidence=0.9,
+ authority_tier="live-infra",
+ ))
+ elif "text" in block:
+ hits.append(Hit(
+ engine="serena",
+ title="symbol result",
+ snippet=str(block["text"])[:300],
+ score=0.6,
+ confidence=0.9,
+ authority_tier="live-infra",
+ ))
+ return hits
+
+
+class SerenaAdapter(BaseAdapter):
+ engine = "serena"
+ authority_tier = "live-infra"
+
+ async def _fetch(self, query: str, k: int) -> list[Hit]:
+ blocks = await call_mcp_tool(
+ SERENA_URL, "find_symbol", {"query": query, "limit": k}
+ )
+ return _parse_serena(blocks)[:k]
cortex/adapters/sources.py +133 -0
@@ -0,0 +1,133 @@
+"""Data-source health aggregation for Darwin's organism.
+
+The Face's organism (health-as-anatomy: a fresh source glows, a stale one
+withers) renders nodes keyed by name. Cortex `/health` historically reported
+only the 5 query engines (rag/cbm/serena/memgraph/inbox). This module enriches
+`/health` with the REAL ingestion data-sources so the organism reflects the
+whole nervous system, not just the engines.
+
+Freshness is read live from the homelab-rag manifest via the RAG bridge's
+`GET /sources` endpoint (CT215:9875), which reads /var/tmp/homelab-rag/state.sqlite
+(max sync_runs.finished_at per source_id). The Deck (CT215:9893/health) supplies
+the live active-agent count for the `agents` node.
+
+Organism node keys (must match DarwinOrganism.tsx SOURCE_CONFIG):
+ proxmox, white-pc, 3060, browser-history, agents
+ (the 5 engines are emitted by main.py, not here)
+
+source_id -> organism node mapping (SOURCE_NODE_MAP). A node may be fed by more
+than one source_id; we take the FRESHEST contributing source (min staleness).
+A node with no contributing source present in the manifest is emitted as
+status:"down" so the organism withers it (e.g. white-pc, which is not yet
+ingesting).
+"""
+from __future__ import annotations
+
+import os
+from datetime import datetime, timezone
+
+import httpx
+
+from models import EngineStatus
+
+RAG_BRIDGE_URL = os.environ.get("RAG_BRIDGE_URL", "http://REDACTED-IP:9875")
+DECK_URL = os.environ.get("DECK_URL", "http://REDACTED-IP:9893")
+
+# Staleness (hours) past which a source is considered "down" for the organism.
+# The organism itself withers at >12h and reads dead at >24h; keep "down" at 48h
+# so a genuinely-broken collector (e.g. ct243-media, days stale) reads as down.
+DOWN_THRESHOLD_H = float(os.environ.get("SOURCE_DOWN_THRESHOLD_H", "48"))
+
+# organism node key -> list of contributing homelab-rag source_ids.
+# When multiple sources feed one node, the freshest (lowest staleness) wins.
+SOURCE_NODE_MAP: dict[str, list[str]] = {
+ "proxmox": ["pve-node"],
+ "white-pc": ["desktop-qi823gr"], # not yet ingesting -> node withers
+ "3060": ["desktop-f8o1v2s"], # RTX 3060 Windows box
+ "browser-history": ["desktop-f8o1v2s-browser"],
+ # agents node = shell-history of the agent hosts; freshness of agent activity.
+ # (Live active-agent COUNT is layered on from the Deck below.)
+ "agents": ["ct215-shell-history", "ct241-shell-history"],
+}
+
+
+async def _fetch_bridge_sources() -> dict:
+ async with httpx.AsyncClient(timeout=3.0) as client:
+ resp = await client.get(f"{RAG_BRIDGE_URL}/sources")
+ resp.raise_for_status()
+ return resp.json()
+
+
+async def _fetch_deck_agents() -> int | None:
+ try:
+ async with httpx.AsyncClient(timeout=2.5) as client:
+ resp = await client.get(f"{DECK_URL}/health")
+ resp.raise_for_status()
+ return int(resp.json().get("active_agents", 0))
+ except Exception:
+ return None
+
+
+def _parse_iso(val: str | None) -> datetime | None:
+ if not val:
+ return None
+ try:
+ return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
+ except Exception:
+ return None
+
+
+async def source_statuses() -> list[EngineStatus]:
+ """Return one EngineStatus per organism data-source node.
+
+ Same schema as the engine entries (engine=node-key, status up/down,
+ last_ingest, staleness_h). Graceful: if the bridge is down, every node is
+ emitted status:"down" so the organism withers rather than vanishing.
+ """
+ try:
+ payload = await _fetch_bridge_sources()
+ by_id = {
+ s["source_id"]: s
+ for s in payload.get("sources", [])
+ if isinstance(s, dict) and s.get("source_id")
+ }
+ except Exception:
+ by_id = {}
+
+ out: list[EngineStatus] = []
+ for node_key, source_ids in SOURCE_NODE_MAP.items():
+ best: dict | None = None
+ for sid in source_ids:
+ s = by_id.get(sid)
+ if not s:
+ continue
+ sh = s.get("staleness_h")
+ if best is None or (sh is not None and (
+ best.get("staleness_h") is None or sh < best["staleness_h"]
+ )):
+ best = s
+
+ if best is None:
+ # No contributing source in the manifest -> withered/down.
+ out.append(EngineStatus(engine=node_key, status="down"))
+ continue
+
+ staleness_h = best.get("staleness_h")
+ status = "up"
+ if staleness_h is None or staleness_h > DOWN_THRESHOLD_H:
+ status = "down"
+ out.append(EngineStatus(
+ engine=node_key,
+ status=status,
+ last_ingest=_parse_iso(best.get("last_ingest")),
+ staleness_h=staleness_h,
+ ))
+
+ # Layer the live active-agent count onto the agents node's ms field so the
+ # Face can surface it without a schema change (EngineStatus.ms is free here).
+ active = await _fetch_deck_agents()
+ if active is not None:
+ for st in out:
+ if st.engine == "agents":
+ st.ms = active
+ return out
cortex/main.py +167 -0
@@ -0,0 +1,167 @@
+"""Darwin Cortex Gateway - FastAPI app.
+
+Port :9890 (HTTP API). MCP SSE shim on :9891 via mcp_server.py + mcp-proxy.
+
+Start:
+ uvicorn main:app --host 0.0.0.0 --port 9890
+"""
+from __future__ import annotations
+
+import asyncio
+import sys
+import os
+import time
+from datetime import datetime, timezone
+from typing import Optional
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+# Add cortex dir to path so imports work when run from anywhere
+sys.path.insert(0, os.path.dirname(__file__))
+
+from models import AskRequest, AskResponse, EngineStatus, HealthResponse, Hit, Receipt
+from ranking import rank_hits, detect_conflicts
+from synthesis import synthesize, warmup as synth_warmup
+import state as live_state
+from adapters.rag import RagAdapter
+from adapters.cbm import CbmAdapter
+from adapters.serena import SerenaAdapter
+from adapters.memgraph import MemgraphAdapter
+from adapters.inbox import InboxAdapter
+from adapters.sources import source_statuses
+
+app = FastAPI(title="Darwin Cortex", version="1.0.0")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+ALL_ADAPTERS = {
+ "rag": RagAdapter(),
+ "cbm": CbmAdapter(),
+ "serena": SerenaAdapter(),
+ "memgraph": MemgraphAdapter(),
+ "inbox": InboxAdapter(),
+}
+
+
+@app.on_event("startup")
+async def _startup() -> None:
+ """Best-effort: warm the LLM synthesizer onto the GPU so the first real
+ /ask hits a resident model instead of a cold load."""
+ asyncio.create_task(synth_warmup())
+
+
+@app.get("/health", response_model=HealthResponse)
+async def health() -> HealthResponse:
+ """Whole-organism liveness: the 5 query engines PLUS the real ingestion
+ data-sources (proxmox/white-pc/3060/browser-history/agents) with live
+ per-source freshness from the homelab-rag manifest. Same EngineStatus
+ schema for every entry; data-source entries are additive and never replace
+ the engine entries, so the response stays backward-compatible."""
+ async def probe(key: str, adapter) -> EngineStatus:
+ _, status = await adapter.query("ping", k=1)
+ return status
+
+ engine_task = asyncio.gather(*(probe(k, v) for k, v in ALL_ADAPTERS.items()))
+ sources_task = source_statuses()
+ engine_statuses, data_sources = await asyncio.gather(
+ engine_task, sources_task, return_exceptions=True,
+ )
+
+ if isinstance(engine_statuses, Exception):
+ engine_statuses = []
+ if isinstance(data_sources, Exception):
+ data_sources = []
+
+ all_statuses = list(engine_statuses) + list(data_sources)
+ ok = any(s.status == "up" for s in all_statuses)
+ return HealthResponse(engines=all_statuses, ok=ok)
+
+
+@app.post("/ask", response_model=AskResponse)
+async def ask(req: AskRequest) -> AskResponse:
+ """Fan out to all engines, reconcile, synthesize."""
+ t0 = time.monotonic()
+
+ # Determine which adapters to query
+ scope = req.scope or list(ALL_ADAPTERS.keys())
+ adapters = {k: v for k, v in ALL_ADAPTERS.items() if k in scope}
+
+ # Detect state-intent and build the live-state snapshot in parallel with the
+ # engine fan-out. Live state is the HIGHEST authority (live infra > docs), so
+ # for state questions it leads the answer; otherwise it's an extra top-authority
+ # context block. Snapshot building is graceful (never raises).
+ state_first = live_state.is_state_query(req.query)
+ snapshot_task = asyncio.create_task(live_state.get_snapshot())
+
+ # Fan out in parallel
+ async def run_adapter(key: str, adapter):
+ return await adapter.query(req.query, k=req.k)
+
+ tasks = {k: asyncio.create_task(run_adapter(k, v)) for k, v in adapters.items()}
+ results = await asyncio.gather(*tasks.values(), return_exceptions=True)
+
+ try:
+ snapshot = await snapshot_task
+ state_block = live_state.render_block(snapshot)
+ except Exception:
+ snapshot, state_block = None, None
+
+ all_hits: list[Hit] = []
+ engine_statuses: list[EngineStatus] = []
+
+ for (key, _), result in zip(tasks.items(), results):
+ if isinstance(result, Exception):
+ engine_statuses.append(EngineStatus(engine=key, status="down"))
+ continue
+ hits, status = result
+ all_hits.extend(hits)
+ engine_statuses.append(status)
+
+ # Rank & reconcile
+ ranked = rank_hits(all_hits, k=req.k * len(adapters))
+ conflicts = detect_conflicts(ranked)
+
+ # Determine answer vs search mode
+ answer: Optional[str] = None
+ receipts = []
+ if req.mode in ("chat", "both"):
+ answer, receipts = await synthesize(
+ req.query,
+ ranked[:req.k],
+ req.synthesize,
+ live_state=state_block,
+ state_first=state_first and state_block is not None,
+ )
+ # Mark live-state provenance: for state questions, lead the receipts with
+ # the live-state source so the Face can show source:"live-state".
+ if state_first and state_block:
+ receipts = [Receipt(
+ id="r0",
+ source="live-state",
+ path="cortex:/health + deck + darwin-term",
+ last_seen=datetime.now(timezone.utc),
+ confidence=1.0,
+ snippet=state_block[:200],
+ )] + list(receipts)
+
+ took_ms = int((time.monotonic() - t0) * 1000)
+
+ return AskResponse(
+ answer=answer,
+ receipts=receipts,
+ hits=ranked[:req.k],
+ conflicts=conflicts,
+ engines=engine_statuses,
+ took_ms=took_ms,
+ )
+
+
+@app.get("/")
+async def root():
+ return {"service": "darwin-cortex", "version": "1.0.0", "docs": "/docs"}
cortex/mcp_server.py +116 -0
@@ -0,0 +1,116 @@
+"""Darwin MCP server - exposes darwin_ask as a single unified tool.
+
+Scaffold: tool definition is complete.
+Wiring: run `python mcp_server.py` to start an stdio MCP server.
+For SSE shim on :9891, use mcp-proxy or the run_sse_async pattern.
+
+To wire into an agent's MCP config:
+ {
+ "mcpServers": {
+ "darwin": {
+ "command": "python",
+ "args": ["/opt/darwin/cortex/mcp_server.py"],
+ "env": {}
+ }
+ }
+ }
+
+For SSE shim at :9891 (remote agents / Cockpit / Odysseus):
+ Run: mcp-proxy --port 9891 -- python /opt/darwin/cortex/mcp_server.py
+ Then register: http://REDACTED-IP:9891/sse
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import sys
+from typing import Any
+
+import httpx
+from mcp.server import Server
+from mcp.server.stdio import stdio_server
+from mcp import types as mcp_types
+
+CORTEX_URL = "http://127.0.0.1:9890"
+
+app = Server("darwin-cortex")
+
+
+@app.list_tools()
+async def list_tools() -> list[mcp_types.Tool]:
+ return [
+ mcp_types.Tool(
+ name="darwin_ask",
+ description=(
+ "Query Darwin's unified second brain. Fans out to homelab-rag (content), "
+ "cbm (code graph), Serena (symbols), memory-graph (decisions), and Memos inbox. "
+ "Returns a ranked answer with clickable receipts and conflict detection. "
+ "Use this instead of calling the four memory tools separately."
+ ),
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Natural-language question",
+ },
+ "mode": {
+ "type": "string",
+ "enum": ["chat", "search", "both"],
+ "default": "both",
+ "description": "chat=LLM synthesis, search=raw hits, both=everything",
+ },
+ "scope": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Limit to specific engines: rag, cbm, serena, memgraph, inbox",
+ },
+ "k": {
+ "type": "integer",
+ "default": 12,
+ "description": "Max hits per engine",
+ },
+ "synthesize": {
+ "type": "boolean",
+ "default": True,
+ "description": "Run LLM synthesis if available",
+ },
+ },
+ "required": ["query"],
+ },
+ )
+ ]
+
+
+@app.call_tool()
+async def call_tool(name: str, arguments: dict[str, Any]) -> list[mcp_types.TextContent]:
+ if name != "darwin_ask":
+ raise ValueError(f"Unknown tool: {name}")
+
+ payload = {
+ "query": arguments["query"],
+ "mode": arguments.get("mode", "both"),
+ "k": arguments.get("k", 12),
+ "synthesize": arguments.get("synthesize", True),
+ }
+ if "scope" in arguments:
+ payload["scope"] = arguments["scope"]
+
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(f"{CORTEX_URL}/ask", json=payload)
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception as e:
+ data = {"error": str(e), "answer": None, "hits": [], "receipts": []}
+
+ return [mcp_types.TextContent(type="text", text=json.dumps(data, default=str))]
+
+
+async def main():
+ async with stdio_server() as (read, write):
+ await app.run(read, write, app.create_initialization_options())
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
cortex/models.py +65 -0
@@ -0,0 +1,65 @@
+"""Darwin Cortex - shared Pydantic v2 models."""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+from pydantic import BaseModel, Field
+
+
+class Hit(BaseModel):
+ """Normalised result from any engine."""
+ engine: str
+ title: str = ""
+ path: str = ""
+ snippet: str = ""
+ score: float = 0.0
+ last_seen: Optional[datetime] = None
+ confidence: float = 0.8
+ # internal - raw authority tier assigned by the adapter
+ authority_tier: str = "older-note" # live-infra | recent-file | agent-memory | older-note
+
+
+class Receipt(BaseModel):
+ id: str
+ source: str
+ path: str
+ last_seen: Optional[datetime] = None
+ confidence: float
+ snippet: str
+
+
+class Conflict(BaseModel):
+ claim: str
+ winner: str
+ losers: list[str]
+ reason: str
+
+
+class EngineStatus(BaseModel):
+ engine: str
+ status: str # up | down
+ ms: Optional[int] = None
+ last_ingest: Optional[datetime] = None
+ staleness_h: Optional[float] = None
+
+
+class AskRequest(BaseModel):
+ query: str
+ mode: str = "both" # chat | search | both
+ scope: Optional[list[str]] = None
+ k: int = 12
+ synthesize: bool = True
+
+
+class AskResponse(BaseModel):
+ answer: Optional[str] = None
+ receipts: list[Receipt] = Field(default_factory=list)
+ hits: list[Hit] = Field(default_factory=list)
+ conflicts: list[Conflict] = Field(default_factory=list)
+ engines: list[EngineStatus] = Field(default_factory=list)
+ took_ms: int = 0
+
+
+class HealthResponse(BaseModel):
+ engines: list[EngineStatus] = Field(default_factory=list)
+ ok: bool = True
cortex/ranking.py +192 -0
@@ -0,0 +1,192 @@
+"""Darwin Cortex - ranking & reconciliation engine.
+
+Score formula (per CONTRACTS.md §3):
+ score = w_authority(source) * recency_decay(last_seen) * confidence * relevance
+
+recency_decay = exp(-age_days / HALF_LIFE) HALF_LIFE default 30d
+"""
+from __future__ import annotations
+
+import math
+from datetime import datetime, timezone
+from typing import Optional
+
+from models import Conflict, Hit
+
+HALF_LIFE_DAYS: float = 30.0
+
+AUTHORITY_WEIGHTS: dict[str, float] = {
+ "live-infra": 1.0,
+ "recent-file": 0.8,
+ "agent-memory": 0.6,
+ "older-note": 0.4,
+}
+
+
+def recency_decay(last_seen: Optional[datetime], half_life: float = HALF_LIFE_DAYS) -> float:
+ """Exponential decay based on age in days. Unknown last_seen → max decay (0.5)."""
+ if last_seen is None:
+ return 0.5
+ now = datetime.now(timezone.utc)
+ if last_seen.tzinfo is None:
+ last_seen = last_seen.replace(tzinfo=timezone.utc)
+ age_days = max(0.0, (now - last_seen).total_seconds() / 86400.0)
+ return math.exp(-age_days / half_life)
+
+
+def authority_weight(tier: str) -> float:
+ return AUTHORITY_WEIGHTS.get(tier, 0.4)
+
+
+def compute_score(hit: Hit, relevance: float = 1.0) -> float:
+ """Apply the Darwin scoring formula to a Hit."""
+ w = authority_weight(hit.authority_tier)
+ d = recency_decay(hit.last_seen)
+ return w * d * hit.confidence * relevance
+
+
+def rank_hits(hits: list[Hit], k: int = 50) -> list[Hit]:
+ """Score and sort a flat list of hits. Mutates hit.score in place.
+
+ Engines (esp. keyword/FTS) frequently return flat or missing relevance
+ scores, which collapses the final scores into a single value and makes the
+ ranking meaningless. To guarantee a meaningful spread we combine the engine's
+ raw relevance with a per-engine rank-position prior, so the top hit per
+ engine gets full relevance and lower hits decay smoothly. The authority ×
+ recency × confidence factors then differentiate across engines/sources.
+ """
+ if not hits:
+ return hits
+
+ # Per-engine ordering by raw engine score (preserve the engine's own sense of
+ # relevance, then apply a rank prior so positions are distinguishable).
+ by_engine: dict[str, list[Hit]] = {}
+ for h in hits:
+ by_engine.setdefault(h.engine, []).append(h)
+
+ for engine_hits in by_engine.values():
+ engine_hits.sort(key=lambda h: h.score, reverse=True)
+ n = len(engine_hits)
+ for rank, h in enumerate(engine_hits):
+ raw = max(0.0, min(1.0, h.score))
+ # Rank prior in (0,1]: 1.0 for the top hit, decaying with position.
+ rank_prior = 1.0 - (rank / (n + 1))
+ # Blend: keep real signal but ensure positions spread.
+ relevance = 0.5 * raw + 0.5 * rank_prior
+ relevance = max(0.05, min(1.0, relevance))
+ h.score = compute_score(h, relevance)
+
+ hits.sort(key=lambda h: h.score, reverse=True)
+ return hits[:k]
+
+
+def detect_conflicts(hits: list[Hit]) -> list[Conflict]:
+ """Simple conflict detection: group hits by (engine, snippet-fingerprint-cluster).
+
+ In practice: any pair where the snippets contain contradictory keywords for the
+ same named entity triggers a conflict. For v1 we implement a lightweight
+ 'same-subject different-assertion' heuristic.
+
+ Two hits share a subject if their titles or paths have significant overlap (Jaccard
+ on tokens ≥ 0.5). If their top-scored hit disagrees with a lower one (score diff > 0)
+ we record it.
+ """
+ conflicts: list[Conflict] = []
+ seen_pairs: set[frozenset] = set()
+
+ for i, a in enumerate(hits):
+ for b in hits[i + 1:]:
+ pair = frozenset([id(a), id(b)])
+ if pair in seen_pairs:
+ continue
+ seen_pairs.add(pair)
+
+ # Check if they discuss the same subject
+ if not _shared_subject(a, b):
+ continue
+
+ # Check if their snippets contain opposing signals
+ opposition = _opposing_claim(a, b)
+ if opposition is None:
+ continue
+
+ # Higher score wins
+ winner, loser = (a, b) if a.score >= b.score else (b, a)
+ age_winner = _age_days(winner.last_seen)
+ age_loser = _age_days(loser.last_seen)
+ reason = (
+ f"{loser.engine} ({loser.authority_tier}) "
+ f"stale {age_loser:.0f}d vs {winner.engine} ({winner.authority_tier}) "
+ f"fresh {age_winner:.0f}d; scores {winner.score:.3f} > {loser.score:.3f}"
+ )
+ conflicts.append(Conflict(
+ claim=opposition,
+ winner=f"{winner.engine}:{winner.authority_tier}",
+ losers=[f"{loser.engine}:{loser.authority_tier}"],
+ reason=reason,
+ ))
+ return conflicts
+
+
+# --- helpers ---
+
+def _age_days(dt: Optional[datetime]) -> float:
+ if dt is None:
+ return 999.0
+ now = datetime.now(timezone.utc)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return max(0.0, (now - dt).total_seconds() / 86400.0)
+
+
+_OPPOSITION_PAIRS: list[tuple[str, str, str]] = [
+ ("active", "stopped", "service status"),
+ ("running", "stopped", "service status"),
+ ("up", "down", "service status"),
+ ("online", "offline", "service status"),
+ ("enabled", "disabled", "service state"),
+ ("deployed", "removed", "deployment state"),
+ ("live", "dead", "liveness"),
+ ("open", "closed", "state"),
+]
+
+
+def _opposing_claim(a: Hit, b: Hit) -> Optional[str]:
+ """Return a claim label if the two hits contain opposing keywords."""
+ text_a = (a.snippet + " " + a.title).lower()
+ text_b = (b.snippet + " " + b.title).lower()
+ for pos, neg, label in _OPPOSITION_PAIRS:
+ if pos in text_a and neg in text_b:
+ return label
+ if neg in text_a and pos in text_b:
+ return label
+ return None
+
+
+def _jaccard(s1: str, s2: str) -> float:
+ t1 = set(s1.lower().split())
+ t2 = set(s2.lower().split())
+ if not t1 or not t2:
+ return 0.0
+ return len(t1 & t2) / len(t1 | t2)
+
+
+def _shared_subject(a: Hit, b: Hit) -> bool:
+ """Return True if the two hits are about the same subject.
+
+ Strategy:
+ 1. Jaccard on (title + path + first 80 chars of snippet) >= 0.2
+ 2. OR: any significant word (len >= 4) appears in both title+snippet combos
+ """
+ combined_a = f"{a.title} {a.path} {a.snippet[:80]}"
+ combined_b = f"{b.title} {b.path} {b.snippet[:80]}"
+
+ if _jaccard(combined_a, combined_b) >= 0.2:
+ return True
+
+ # Named-entity overlap: shared non-stopword tokens of length >= 4
+ _STOPWORDS = {"this", "that", "with", "from", "have", "been", "will", "into",
+ "running", "service", "status", "result", "value"}
+ words_a = {w for w in combined_a.lower().split() if len(w) >= 4 and w not in _STOPWORDS}
+ words_b = {w for w in combined_b.lower().split() if len(w) >= 4 and w not in _STOPWORDS}
+ return bool(words_a & words_b)
cortex/requirements.txt +8 -0
@@ -0,0 +1,8 @@
+fastapi>=0.110.0
+uvicorn[standard]>=0.29.0
+httpx>=0.27.0
+pydantic>=2.0.0
+mcp>=1.0.0
+pytest>=8.0.0
+pytest-asyncio>=0.23.0
+anyio>=4.0.0
cortex/state.py +393 -0
@@ -0,0 +1,393 @@
+"""Darwin Cortex - LIVE-STATE awareness.
+
+Per Darwin's source-of-truth hierarchy, LIVE INFRA is the HIGHEST authority
+(live infra > docs). This module builds a compact, structured snapshot of the
+organism's REAL current state - pulled live, not from RAG documents - so that
+status/state questions ("is white-pc down?", "what's stale?", "what are my
+agents doing?", "how many live sessions?") get answered from ground truth.
+
+Sources (all reachable from the Cortex host):
+ 1. Cortex /health (SELF) - the 10 nodes: 5 engines + 5 data-sources
+ with per-source freshness. Called in-process
+ (build_health_snapshot) - no self-HTTP.
+ 2. Deck :9893/health + /tree - active agents, by host/model, what they are.
+ 3. darwin-term :9894/sessions - live tmux sessions across ct215/241/247.
+
+Every source is wrapped in try/except + a short timeout, so a dead source
+degrades the snapshot gracefully and NEVER breaks /ask. The whole snapshot is
+cached for a few seconds so repeated /ask calls don't re-probe every time.
+
+The snapshot is exposed two ways:
+ - as_context_block(): a compact top-authority text block fed to the synthesizer
+ - state-intent detection: is_state_query() routes status questions to lead
+ with this live snapshot instead of RAG.
+"""
+from __future__ import annotations
+
+import os
+import re
+import time
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+import httpx
+
+DECK_URL = os.environ.get("DECK_URL", "http://REDACTED-IP:9893")
+DARWIN_TERM_URL = os.environ.get("DARWIN_TERM_URL", "http://REDACTED-IP:9894")
+DARWIN_TERM_TOKEN = os.environ.get("DARWIN_TERM_TOKEN", "")
+RAG_BRIDGE_URL = os.environ.get("RAG_BRIDGE_URL", "http://REDACTED-IP:9875")
+
+CACHE_TTL_S = float(os.environ.get("DARWIN_STATE_CACHE_TTL", "8"))
+_PROBE_TIMEOUT = 3.0
+
+# Staleness (hours) past which a data-source reads as "stale" in the snapshot.
+STALE_THRESHOLD_H = float(os.environ.get("SOURCE_STALE_THRESHOLD_H", "12"))
+
+_cache: dict[str, Any] = {"snapshot": None, "ts": 0.0}
+
+
+# --------------------------------------------------------------------------
+# State-intent detection
+# --------------------------------------------------------------------------
+# Status/state questions: "is X up/down/live/stale/running/active/asleep",
+# "what's stale", "what are my agents/sessions doing", "how many sessions",
+# "status of my infra/ingestion", "what's happening".
+_STATE_PATTERNS = [
+ re.compile(r"\b(is|are|was|were)\b.*\b(up|down|live|stale|running|active|asleep|alive|dead|online|offline|reachable)\b", re.I),
+ re.compile(r"\bwhat('?s| is| are)\b.*\b(stale|status|happening|going on|running|active|live|down|up)\b", re.I),
+ re.compile(r"\bwhat\b.*\b(agents?|sessions?|engines?|sources?|nodes?|containers?|cts?)\b.*\b(doing|running|active|up|down|live|stale)\b", re.I),
+ re.compile(r"\bhow many\b.*\b(sessions?|agents?|engines?|sources?|nodes?|containers?|cts?)\b", re.I),
+ re.compile(r"\b(status|health|state)\b.*\b(infra|infrastructure|ingestion|ingest|organism|system|darwin|engines?|sources?|agents?|sessions?)\b", re.I),
+ re.compile(r"\bwhat are my\b.*\b(agents?|sessions?|engines?|sources?)\b", re.I),
+ re.compile(r"\b(anything|what'?s?)\s+(stale|down|broken|offline|asleep)\b", re.I),
+]
+
+
+def is_state_query(query: str) -> bool:
+ """True when the query is asking about live state/status, not knowledge."""
+ if not query:
+ return False
+ q = query.strip()
+ return any(p.search(q) for p in _STATE_PATTERNS)
+
+
+# --------------------------------------------------------------------------
+# Source probes (each graceful)
+# --------------------------------------------------------------------------
+async def _build_health_snapshot() -> dict | None:
+ """Call the Cortex /health builder in-process (no self-HTTP) so the live
+ organism state (engines + data-source freshness, incl. white-pc DOWN and
+ ct243-media stale) is part of the snapshot."""
+ try:
+ # Local import to avoid a circular import at module load.
+ from main import health as _health # type: ignore
+ resp = await _health()
+ engines = []
+ for s in resp.engines:
+ engines.append({
+ "name": s.engine,
+ "status": s.status,
+ "staleness_h": s.staleness_h,
+ "last_ingest": s.last_ingest.isoformat() if s.last_ingest else None,
+ "ms": s.ms,
+ })
+ return {"ok": resp.ok, "engines": engines}
+ except Exception:
+ return None
+
+
+async def _probe_deck() -> dict | None:
+ try:
+ async with httpx.AsyncClient(timeout=_PROBE_TIMEOUT) as client:
+ h = await client.get(f"{DECK_URL}/health")
+ h.raise_for_status()
+ health = h.json()
+ agents: list[dict] = []
+ try:
+ t = await client.get(f"{DECK_URL}/tree")
+ t.raise_for_status()
+ nodes = t.json().get("nodes", [])
+ for n in nodes:
+ if not isinstance(n, dict):
+ continue
+ if n.get("status") != "running":
+ continue
+ model = n.get("model", "")
+ # skip the synthetic orchestrator/system scaffolding nodes
+ if model in ("system", "orchestrator"):
+ continue
+ agents.append({
+ "host": n.get("host"),
+ "model": model,
+ "label": (n.get("label") or "")[:90],
+ "tool": n.get("current_tool"),
+ })
+ except Exception:
+ pass
+ return {
+ "active_agents": int(health.get("active_agents", 0)),
+ "total_agents": int(health.get("total_agents", 0)),
+ "error_agents": int(health.get("error_agents", 0)),
+ "running": agents,
+ }
+ except Exception:
+ return None
+
+
+async def _probe_ingestion() -> dict | None:
+ """Read ALL homelab-rag ingestion sources directly from the bridge /sources.
+
+ The Cortex /health organism only surfaces 5 mapped nodes, so a stale source
+ that isn't a mapped organism node (e.g. ct243-media, days stale) would never
+ appear. This probe captures the FULL ingestion picture so 'what's stale'
+ names every stale/down source by its real source_id.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=_PROBE_TIMEOUT) as client:
+ r = await client.get(f"{RAG_BRIDGE_URL}/sources")
+ r.raise_for_status()
+ payload = r.json()
+ sources = payload.get("sources", []) if isinstance(payload, dict) else []
+ total = 0
+ stale: list[dict] = []
+ freshest = None
+ for s in sources:
+ if not isinstance(s, dict):
+ continue
+ total += 1
+ sh = s.get("staleness_h")
+ if sh is not None:
+ if freshest is None or sh < freshest:
+ freshest = sh
+ if sh > STALE_THRESHOLD_H:
+ stale.append({"id": s.get("source_id"), "staleness_h": sh})
+ stale.sort(key=lambda x: -(x["staleness_h"] or 0))
+ return {"total": total, "stale": stale, "freshest_h": freshest}
+ except Exception:
+ return None
+
+
+async def _probe_term() -> dict | None:
+ if not DARWIN_TERM_TOKEN:
+ return None
+ try:
+ async with httpx.AsyncClient(timeout=_PROBE_TIMEOUT) as client:
+ r = await client.get(
+ f"{DARWIN_TERM_URL}/sessions",
+ params={"token": DARWIN_TERM_TOKEN},
+ )
+ r.raise_for_status()
+ sessions = r.json().get("sessions", [])
+ by_host: dict[str, int] = {}
+ by_agent: dict[str, int] = {}
+ for s in sessions:
+ if not isinstance(s, dict):
+ continue
+ by_host[s.get("target", "?")] = by_host.get(s.get("target", "?"), 0) + 1
+ by_agent[s.get("agent", "?")] = by_agent.get(s.get("agent", "?"), 0) + 1
+ return {
+ "count": len(sessions),
+ "by_host": by_host,
+ "by_agent": by_agent,
+ }
+ except Exception:
+ return None
+
+
+# --------------------------------------------------------------------------
+# Snapshot assembly + rendering
+# --------------------------------------------------------------------------
+async def get_snapshot() -> dict:
+ """Return the live-state snapshot, cached for CACHE_TTL_S seconds."""
+ now = time.monotonic()
+ snap = _cache.get("snapshot")
+ if snap is not None and (now - _cache["ts"]) < CACHE_TTL_S:
+ return snap
+
+ import asyncio
+ health, deck, term, ingestion = await asyncio.gather(
+ _build_health_snapshot(), _probe_deck(), _probe_term(), _probe_ingestion(),
+ return_exceptions=True,
+ )
+ snap = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "health": health if isinstance(health, dict) else None,
+ "deck": deck if isinstance(deck, dict) else None,
+ "term": term if isinstance(term, dict) else None,
+ "ingestion": ingestion if isinstance(ingestion, dict) else None,
+ }
+ _cache["snapshot"] = snap
+ _cache["ts"] = now
+ return snap
+
+
+def _render_health(health: dict | None) -> list[str]:
+ if not health:
+ return ["Organism /health: UNAVAILABLE (could not read live health)."]
+ lines: list[str] = []
+ engines = health.get("engines", [])
+ up = [e for e in engines if e.get("status") == "up"]
+ down = [e for e in engines if e.get("status") != "up"]
+ stale = [
+ e for e in engines
+ if e.get("staleness_h") is not None and e["staleness_h"] > STALE_THRESHOLD_H
+ ]
+ lines.append(
+ f"Organism: {len(up)}/{len(engines)} nodes up. "
+ f"Down: {', '.join(e['name'] for e in down) if down else 'none'}."
+ )
+ if stale:
+ stale_desc = ", ".join(
+ f"{e['name']} (~{e['staleness_h']:.0f}h stale)" for e in stale
+ )
+ lines.append(f"Stale data-sources (>{STALE_THRESHOLD_H:.0f}h): {stale_desc}.")
+ else:
+ lines.append("No data-source is stale beyond threshold.")
+ # Per-node one-liner for precise lookups (e.g. "is white-pc down").
+ detail = []
+ for e in engines:
+ bit = e["name"] + "=" + e["status"]
+ if e.get("staleness_h") is not None:
+ bit += f"({e['staleness_h']:.1f}h)"
+ detail.append(bit)
+ lines.append("Nodes: " + ", ".join(detail) + ".")
+ return lines
+
+
+def _render_ingestion(ing: dict | None) -> list[str]:
+ if not ing:
+ return ["Ingestion sources: UNAVAILABLE."]
+ stale = ing.get("stale") or []
+ total = ing.get("total", 0)
+ if not stale:
+ return [f"Ingestion: all {total} sources fresh (freshest ~{ing.get('freshest_h')}h)."]
+ desc = ", ".join(
+ f"{s['id']} (~{s['staleness_h']:.0f}h stale)" for s in stale
+ )
+ return [f"Ingestion: {len(stale)}/{total} sources STALE (>{STALE_THRESHOLD_H:.0f}h): {desc}."]
+
+
+def _render_deck(deck: dict | None) -> list[str]:
+ if not deck:
+ return ["Agents (Deck): UNAVAILABLE."]
+ lines = [
+ f"Active agents right now: {deck.get('active_agents', 0)} "
+ f"(total ever: {deck.get('total_agents', 0)}, errored: {deck.get('error_agents', 0)})."
+ ]
+ running = deck.get("running") or []
+ if running:
+ for a in running[:6]:
+ label = a.get("label") or "(no label)"
+ tool = f", using {a['tool']}" if a.get("tool") else ""
+ lines.append(
+ f" - {a.get('host', '?')}/{a.get('model', '?')}: {label}{tool}"
+ )
+ return lines
+
+
+def _render_term(term: dict | None) -> list[str]:
+ if not term:
+ return ["Live tmux sessions (darwin-term): UNAVAILABLE."]
+ by_host = ", ".join(f"{h}:{n}" for h, n in (term.get("by_host") or {}).items())
+ by_agent = ", ".join(f"{a}:{n}" for a, n in (term.get("by_agent") or {}).items())
+ return [
+ f"Live tmux sessions: {term.get('count', 0)} total"
+ + (f" (by host: {by_host})" if by_host else "")
+ + (f" (by type: {by_agent})" if by_agent else "")
+ + "."
+ ]
+
+
+def render_block(snap: dict) -> str:
+ """Compact human/LLM-readable live-state block (top authority)."""
+ lines = ["LIVE STATE (authoritative - live infra outranks docs):"]
+ lines += _render_health(snap.get("health"))
+ lines += _render_ingestion(snap.get("ingestion"))
+ lines += _render_deck(snap.get("deck"))
+ lines += _render_term(snap.get("term"))
+ return "\n".join(lines)
+
+
+async def as_context_block() -> str:
+ """Convenience: snapshot -> rendered block."""
+ return render_block(await get_snapshot())
+
+
+def deterministic_state_answer(query: str, snap: dict) -> str:
+ """A direct, LLM-free answer for state questions, built from the snapshot.
+
+ Used (a) as the deterministic fallback when the LLM is cold, and (b) to LEAD
+ the answer for state-intent queries. Tries to answer the specific question;
+ always appends the compact state block as evidence.
+ """
+ q = query.lower()
+ health = snap.get("health") or {}
+ engines = {e["name"]: e for e in (health.get("engines") or [])}
+ lead: Optional[str] = None
+
+ # Direct node up/down lookups (white-pc, 3060, proxmox, etc.)
+ alias = {
+ "white pc": "white-pc", "white-pc": "white-pc", "whitepc": "white-pc",
+ "3060": "3060", "rtx": "3060", "gpu": "3060",
+ "proxmox": "proxmox", "pve": "proxmox",
+ "browser": "browser-history", "browser history": "browser-history",
+ }
+ target = None
+ for k, v in alias.items():
+ if k in q:
+ target = v
+ break
+ if target and target in engines:
+ e = engines[target]
+ if e["status"] != "up":
+ lead = f"Yes - {target} is DOWN (not ingesting / no live data)."
+ else:
+ sh = e.get("staleness_h")
+ fresh = f"last ingest ~{sh:.1f}h ago" if sh is not None else "live"
+ lead = f"No - {target} is UP ({fresh})."
+
+ # "what's stale" / "anything stale"
+ if lead is None and ("stale" in q or "out of date" in q or "outdated" in q):
+ ing = snap.get("ingestion") or {}
+ stale_src = ing.get("stale") or []
+ down = [e for e in (health.get("engines") or []) if e.get("status") != "up"]
+ parts = []
+ if stale_src:
+ parts.append(
+ "stale ingestion: " + ", ".join(
+ f"{s['id']} (~{s['staleness_h']:.0f}h)" for s in stale_src
+ )
+ )
+ if down:
+ parts.append("down/not-ingesting: " + ", ".join(e["name"] for e in down))
+ lead = ("What's stale right now - " + "; ".join(parts) + ".") if parts \
+ else "Nothing is stale right now; all data-sources are fresh."
+
+ # sessions count
+ if lead is None and "session" in q:
+ term = snap.get("term")
+ if term:
+ by_host = ", ".join(f"{h}:{n}" for h, n in (term.get("by_host") or {}).items())
+ lead = (
+ f"You have {term.get('count', 0)} live tmux sessions"
+ + (f" ({by_host})" if by_host else "") + "."
+ )
+
+ # agents activity / count
+ if lead is None and "agent" in q:
+ deck = snap.get("deck")
+ if deck:
+ running = deck.get("running") or []
+ if running:
+ summ = "; ".join(
+ f"{a.get('host', '?')}/{a.get('model', '?')} - {(a.get('label') or '')[:60]}"
+ for a in running[:4]
+ )
+ lead = f"{deck.get('active_agents', 0)} agents active right now: {summ}."
+ else:
+ lead = f"{deck.get('active_agents', 0)} agents active right now."
+
+ block = render_block(snap)
+ if lead:
+ return f"{lead}\n\n{block}"
+ return block
cortex/synthesis.py +365 -0
@@ -0,0 +1,365 @@
+"""Darwin Cortex - synthesis layer.
+
+Two paths:
+1. Deterministic ranked-merge (always works, no LLM required):
+ top-k hits → templated answer + receipts.
+
+2. LLM synthesis (pluggable, gated behind synthesize=True + backend availability):
+ - qwen3:8b on 3060 (http://REDACTED-IP:11434/api/chat, think:false) - primary
+ - Claude Opus (gated behind DARWIN_CLAUDE_API_KEY + DARWIN_ENABLE_OPUS=true) - hard questions
+
+Current 3060 state: may be DOWN → graceful fallback to deterministic path.
+"""
+from __future__ import annotations
+
+import os
+from typing import Optional
+
+import httpx
+
+from models import Hit, Receipt
+
+QWEN_URL = os.environ.get("DARWIN_QWEN_URL", "http://REDACTED-IP:11434/api/chat")
+QWEN_MODEL = os.environ.get("DARWIN_QWEN_MODEL", "qwen3:8b")
+QWEN_TIMEOUT = float(os.environ.get("DARWIN_QWEN_TIMEOUT", "20"))
+# Keep the model resident on the GPU after the first call so subsequent /ask
+# calls hit a warm model (1-3s) instead of a cold load that blows the timeout.
+QWEN_KEEP_ALIVE = os.environ.get("DARWIN_QWEN_KEEP_ALIVE", "30m")
+
+ENABLE_OPUS = os.environ.get("DARWIN_ENABLE_OPUS", "false").lower() == "true"
+CLAUDE_API_KEY = os.environ.get("DARWIN_CLAUDE_API_KEY", "")
+
+
+def _make_receipts(hits: list[Hit], max_receipts: int = 6) -> list[Receipt]:
+ receipts = []
+ for i, h in enumerate(hits[:max_receipts]):
+ receipts.append(Receipt(
+ id=f"r{i+1}",
+ source=h.engine,
+ path=h.path,
+ last_seen=h.last_seen,
+ confidence=h.confidence,
+ snippet=h.snippet[:200],
+ ))
+ return receipts
+
+
+def _clean_snippet(text: str, limit: int = 240) -> str:
+ """Flatten a snippet into one prose-ish sentence-fragment: strip markdown
+ bullets/headers/backticks and collapse whitespace."""
+ import re
+
+ t = re.sub(r"[`*#>_]", "", text or "")
+ t = re.sub(r"\s+", " ", t).strip()
+ # Trim leading list markers like "- " or "1. "
+ t = re.sub(r"^(?:[-•]\s*|\d+\.\s*)", "", t)
+ if len(t) > limit:
+ t = t[: limit - 1].rstrip() + "…"
+ return t
+
+
+# Keys that carry a real human-readable description, best first.
+_DESC_KEY_PRIORITY = ("summary", "description", "desc", "note", "title")
+# Keys that are machine metadata / state - never chosen as the descriptive text.
+_JUNK_KEYS = {
+ "date", "ts", "timestamp", "updated", "created", "endpoint", "url", "urls",
+ "uri", "backups", "backup", "registered_agents", "agents", "service",
+ "shim", "status", "state", "id", "ids", "path", "paths", "score", "engine",
+ "source", "authority", "authority_tier", "type", "kind", "version",
+}
+
+
+def _extract_descriptive(snippet: str, title: str = "", limit: int = 400) -> tuple[str, int]:
+ """Pull the single most descriptive value out of a snippet.
+
+ Darwin memory-graph snippets are often pipe-delimited metadata like
+ "status: DEPLOYED | summary: <real prose> | date: ...". This splits on
+ " | ", parses "key: value" pairs, and returns the value of the highest-
+ priority descriptive key (summary > description > desc > note > title,
+ then the first non-junk value). Junk keys (date/endpoint/status/...) are
+ never chosen. Returns (text, rank) where lower rank = more descriptive.
+ Falls back to the cleaned snippet (or title) when not pipe-delimited.
+ """
+ s = _clean_snippet(snippet or "", limit=limit)
+ if " | " in s:
+ pairs: dict[str, str] = {}
+ order: list[str] = []
+ for part in s.split(" | "):
+ k, sep, v = part.partition(":")
+ k = k.strip().lower()
+ v = v.strip()
+ if k and sep and v:
+ pairs[k] = v
+ if k not in order:
+ order.append(k)
+ if pairs:
+ for rank, pk in enumerate(_DESC_KEY_PRIORITY):
+ if pairs.get(pk):
+ return pairs[pk], rank
+ # No priority key present: take the first non-junk value.
+ for k in order:
+ if k not in _JUNK_KEYS and pairs[k]:
+ return pairs[k], len(_DESC_KEY_PRIORITY)
+ return "", 999
+ # Not pipe-delimited: use the cleaned snippet, or fall back to the title.
+ if s:
+ return s, len(_DESC_KEY_PRIORITY)
+ return _clean_snippet(title or "", limit=limit), len(_DESC_KEY_PRIORITY)
+
+
+def _strip_prose_junk(text: str) -> str:
+ """Defensive net: ensure no pipe-chains, graph arrows, or raw URLs survive
+ into the final prose. _extract_descriptive should already have handled
+ pipes, but we never emit "key: value | key: value", "--[...]-->", or
+ http/memory:// URLs regardless."""
+ import re
+
+ t = text or ""
+ # Drop graph-relation arrows: "X --[rel]--> Y" / "X --[rel]-- Y".
+ t = re.sub(r"--\s*\[[^\]]*\]\s*--*>?", "", t)
+ # Never emit a surviving pipe-delimited chain - keep only the first segment.
+ t = t.split(" | ")[0]
+ # Strip raw URLs / resource paths.
+ t = re.sub(r"https?://\S+", "", t)
+ t = re.sub(r"\b(?:memory|file|s3|ftp)://\S+", "", t)
+ t = re.sub(r"\s+", " ", t).strip(" ;,|")
+ return t
+
+
+def _is_noise(text: str) -> bool:
+ """True if the extracted text is graph-relation noise, empty, or just an
+ id/url - i.e. not worth a clause in the answer."""
+ import re
+
+ t = (text or "").strip()
+ if not t:
+ return True
+ if re.search(r"--\s*\[[^\]]*\]\s*--*>?", t):
+ return True
+ if re.match(r"^(https?|memory|file|s3|ftp)://", t, re.I):
+ return True
+ if len(t) < 8:
+ return True
+ return False
+
+
+def _to_sentence(text: str) -> str:
+ """Make a fragment read as a standalone sentence: leading cap + terminal
+ punctuation."""
+ t = (text or "").strip()
+ if not t:
+ return t
+ t = t[0].upper() + t[1:]
+ if t[-1] not in ".!?":
+ t += "."
+ return t
+
+
+def deterministic_answer(query: str, hits: list[Hit]) -> tuple[str, list[Receipt]]:
+ """Deterministic (no-LLM) fallback answer.
+
+ Parses each top hit's snippet (handling Darwin's pipe-delimited
+ "key: value | key: value" metadata format), extracts the single most
+ descriptive value per hit, drops graph-relation / id-only noise, then
+ composes a short answer that LEADS with the most descriptive fact and
+ folds in 0-2 more distinct facts - each tagged with an inline [rN] marker
+ that maps back to its receipt. Never emits raw pipe-junk, graph arrows,
+ or URLs in the prose."""
+ receipts = _make_receipts(hits)
+ if not hits:
+ answer = f"I couldn't find anything in the indexed sources for \"{query}\"."
+ return answer, []
+
+ # Extract one descriptive value per top hit, filter out noise.
+ # facts: list of (receipt_index, text, rank) - rank lower = more descriptive.
+ facts: list[tuple[int, str, int]] = []
+ seen: set[str] = set()
+ for i, h in enumerate(hits[:3]):
+ text, rank = _extract_descriptive(h.snippet or "", h.title or "")
+ # Check noise on the extracted text BEFORE stripping, otherwise
+ # _strip_prose_junk would delete the arrow and disguise graph-relation
+ # noise ("X --[rel]--> Y") as plain prose.
+ if _is_noise(text):
+ continue
+ text = _strip_prose_junk(text)
+ if _is_noise(text):
+ continue
+ key = text[:60].lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ facts.append((i, text, rank))
+
+ if not facts:
+ first = _clean_snippet(hits[0].title or hits[0].path or "the top reference", limit=80)
+ answer = (
+ f'I have indexed references to "{query}" but no source with a clear '
+ f'description - the top reference is {first}. [r1]'
+ )
+ return answer, receipts
+
+ # Lead with the most descriptive fact (best priority key, then longest),
+ # then fold in up to 2 more distinct facts. Markers stay tied to each
+ # fact's original hit index so [rN] always points at the right receipt.
+ facts.sort(key=lambda f: (f[2], -len(f[1])))
+ best = facts[0]
+ rest = facts[1:3]
+
+ parts = [f"{_to_sentence(best[1])} [r{best[0] + 1}]"]
+ for idx, text, _rank in rest:
+ parts.append(f"{_to_sentence(text)} [r{idx + 1}]")
+
+ answer = " ".join(parts)
+ return answer, receipts
+
+
+async def _try_qwen(query: str, context: str, prefer_state: bool = False) -> Optional[str]:
+ """Try qwen3:8b. Returns None if unavailable."""
+ if prefer_state:
+ prompt = (
+ f"You are Darwin, a second-brain assistant. The context below is the "
+ f"REAL, LIVE state of the system - the highest authority (live infra "
+ f"outranks any document).\n\n"
+ f"HOW TO READ THE LIVE STATE:\n"
+ f"- It is split into SEPARATE sections that describe DIFFERENT things: "
+ f"engine/node health (up/down), data-source staleness, ingestion-source "
+ f"staleness, active agents, and tmux sessions. Do NOT conflate them. "
+ f"\"no data-source stale\" is NOT the same as \"no ingestion source stale\"; "
+ f"\"all nodes up\" is NOT the same as \"no stale data\". Each section is "
+ f"independent.\n"
+ f"- Scan the ENTIRE block, every section, before answering. The answer to "
+ f"\"what's stale\" or \"what's down\" may live in a later section - never stop "
+ f"at the first line that mentions staleness or health.\n\n"
+ f"ANSWER RULES:\n"
+ f"1. Lead with the direct answer in your first sentence.\n"
+ f"2. NAME the specific offending items with their real numbers - e.g. which "
+ f"exact ingestion source is stale and how many hours (\"ct243-media (~135h "
+ f"stale)\"), which exact nodes are down, how many of N. Never generalize as "
+ f"\"nothing is stale\" or \"all up\" while ANY line in the block flags an item "
+ f"STALE or down - enumerate those items by name. \"nothing\" is only valid if "
+ f"zero sections report any STALE/down item.\n"
+ f"3. Stay concise: 1-4 sentences, plain prose.\n"
+ f"4. Do NOT add [rN] receipt markers - this is live infra, not receipts.\n"
+ f"5. If the question asks about a category the block genuinely says nothing "
+ f"about, say so in one sentence.\n\n"
+ f"Live state:\n{context}\n\nQuestion: {query}\n\nAnswer:"
+ )
+ else:
+ prompt = (
+ f"You are Darwin, a personal second-brain assistant. A colleague asked "
+ f"you a question and you are translating the answer for them - explain it "
+ f"clearly, like a knowledgeable colleague, NOT like a search results page.\n\n"
+ f"RULES:\n"
+ f"1. ANSWER the question directly. Lead with the answer in your very first "
+ f"sentence - no preamble, no \"based on the sources\", no \"here is\".\n"
+ f"2. SYNTHESIZE across ALL the context entries collectively to form one "
+ f"coherent explanation. Do NOT quote or recite a single source verbatim. "
+ f"Do NOT output a numbered list of results. Do NOT say which source is "
+ f"\"most relevant\" or \"most recent\".\n"
+ f"3. Stay grounded: every factual claim must come from the context - never "
+ f"invent. Right after the clause a piece of context supports, drop an inline "
+ f"marker like [r1], [r2], ... (context entries are numbered [1], [2], ... so "
+ f"map [1]->[r1], [2]->[r2], etc.).\n"
+ f"4. Be concise: 2-5 sentences, flowing prose. No meta commentary.\n"
+ f"5. If a [LIVE STATE] block is present it is the HIGHEST authority and "
+ f"overrides any document - prefer it on conflicts.\n"
+ f"6. If the context genuinely does not answer the question, say so briefly "
+ f"in one sentence.\n\n"
+ f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:"
+ )
+ payload = {
+ "model": QWEN_MODEL,
+ "messages": [{"role": "user", "content": prompt}],
+ "stream": False,
+ "keep_alive": QWEN_KEEP_ALIVE,
+ "options": {"think": False},
+ }
+ try:
+ async with httpx.AsyncClient(timeout=QWEN_TIMEOUT) as client:
+ resp = await client.post(QWEN_URL, json=payload)
+ resp.raise_for_status()
+ data = resp.json()
+ content = (data.get("message") or {}).get("content", "")
+ content = content.strip()
+ return content or None
+ except Exception:
+ return None
+
+
+async def warmup() -> bool:
+ """Fire a tiny ping so the model loads onto the GPU at service start and
+ stays resident (keep_alive). Best-effort; never raises."""
+ payload = {
+ "model": QWEN_MODEL,
+ "messages": [{"role": "user", "content": "ok"}],
+ "stream": False,
+ "keep_alive": QWEN_KEEP_ALIVE,
+ "options": {"think": False, "num_predict": 1},
+ }
+ try:
+ async with httpx.AsyncClient(timeout=max(QWEN_TIMEOUT, 60.0)) as client:
+ resp = await client.post(QWEN_URL, json=payload)
+ resp.raise_for_status()
+ return True
+ except Exception:
+ return False
+
+
+async def synthesize(
+ query: str,
+ hits: list[Hit],
+ synthesize_flag: bool = True,
+ live_state: Optional[str] = None,
+ state_first: bool = False,
+) -> tuple[Optional[str], list[Receipt]]:
+ """Return (answer_str_or_None, receipts).
+
+ Falls back to deterministic if LLM unavailable or synthesize=False.
+
+ live_state: an optional compact LIVE-STATE block. Per Darwin's source-of-truth
+ hierarchy live infra is the HIGHEST authority, so when present it is fed to
+ the synthesizer as the top-authority context (above RAG hits).
+ state_first: when True the query is a status/state question - the live snapshot
+ leads the answer, and a cold LLM falls back to a snapshot-driven answer.
+ """
+ receipts = _make_receipts(hits)
+
+ # State-intent path: lead with live state. Works even with no RAG hits and
+ # even with a cold LLM.
+ if state_first and live_state:
+ from state import get_snapshot, deterministic_state_answer
+ snap = await get_snapshot()
+ if synthesize_flag:
+ llm_answer = await _try_qwen(query, live_state, prefer_state=True)
+ if llm_answer:
+ return llm_answer, receipts
+ return deterministic_state_answer(query, snap), receipts
+
+ if not synthesize_flag or not hits:
+ if hits:
+ answer, receipts = deterministic_answer(query, hits)
+ return answer, receipts
+ return None, []
+
+ # Build context string from top hits
+ context_parts = []
+ if live_state:
+ context_parts.append(f"[LIVE STATE - highest authority]\n{live_state}")
+ for i, h in enumerate(hits[:8]):
+ context_parts.append(
+ f"[{i+1}] Source: {h.engine} ({h.authority_tier}), "
+ f"path: {h.path}, score: {h.score:.3f}\n{h.snippet}"
+ )
+ context = "\n\n".join(context_parts)
+
+ # Try qwen3 first
+ llm_answer = await _try_qwen(query, context)
+ if llm_answer:
+ return llm_answer, receipts
+
+ # TODO: Try Claude Opus if ENABLE_OPUS and CLAUDE_API_KEY set
+ # (gated behind env flags, not implemented in v1)
+
+ # Fallback to deterministic
+ answer, receipts = deterministic_answer(query, hits)
+ return answer, receipts
cortex/synthesis.py.bak-recitefix-20260624-172418 +203 -0
@@ -0,0 +1,203 @@
+"""Darwin Cortex - synthesis layer.
+
+Two paths:
+1. Deterministic ranked-merge (always works, no LLM required):
+ top-k hits → templated answer + receipts.
+
+2. LLM synthesis (pluggable, gated behind synthesize=True + backend availability):
+ - qwen3:8b on 3060 (http://REDACTED-IP:11434/api/chat, think:false) - primary
+ - Claude Opus (gated behind DARWIN_CLAUDE_API_KEY + DARWIN_ENABLE_OPUS=true) - hard questions
+
+Current 3060 state: may be DOWN → graceful fallback to deterministic path.
+"""
+from __future__ import annotations
+
+import os
+from typing import Optional
+
+import httpx
+
+from models import Hit, Receipt
+
+QWEN_URL = os.environ.get("DARWIN_QWEN_URL", "http://REDACTED-IP:11434/api/chat")
+QWEN_MODEL = os.environ.get("DARWIN_QWEN_MODEL", "qwen3:8b")
+QWEN_TIMEOUT = float(os.environ.get("DARWIN_QWEN_TIMEOUT", "20"))
+# Keep the model resident on the GPU after the first call so subsequent /ask
+# calls hit a warm model (1-3s) instead of a cold load that blows the timeout.
+QWEN_KEEP_ALIVE = os.environ.get("DARWIN_QWEN_KEEP_ALIVE", "30m")
+
+ENABLE_OPUS = os.environ.get("DARWIN_ENABLE_OPUS", "false").lower() == "true"
+CLAUDE_API_KEY = os.environ.get("DARWIN_CLAUDE_API_KEY", "")
+
+
+def _make_receipts(hits: list[Hit], max_receipts: int = 6) -> list[Receipt]:
+ receipts = []
+ for i, h in enumerate(hits[:max_receipts]):
+ receipts.append(Receipt(
+ id=f"r{i+1}",
+ source=h.engine,
+ path=h.path,
+ last_seen=h.last_seen,
+ confidence=h.confidence,
+ snippet=h.snippet[:200],
+ ))
+ return receipts
+
+
+def _clean_snippet(text: str, limit: int = 240) -> str:
+ """Flatten a snippet into one prose-ish sentence-fragment: strip markdown
+ bullets/headers/backticks and collapse whitespace."""
+ import re
+
+ t = re.sub(r"[`*#>_]", "", text or "")
+ t = re.sub(r"\s+", " ", t).strip()
+ # Trim leading list markers like "- " or "1. "
+ t = re.sub(r"^(?:[-•]\s*|\d+\.\s*)", "", t)
+ if len(t) > limit:
+ t = t[: limit - 1].rstrip() + "…"
+ return t
+
+
+def deterministic_answer(query: str, hits: list[Hit]) -> tuple[str, list[Receipt]]:
+ """Build a deterministic answer that reads like a real answer: a concise
+ prose synthesis of the top relevant hit(s), leading with the takeaway, with
+ receipt markers - NOT a numbered "Found N results" dump."""
+ receipts = _make_receipts(hits)
+ if not hits:
+ answer = f"I couldn't find anything in the indexed sources for \"{query}\"."
+ return answer, []
+
+ top = hits[0]
+ lead = _clean_snippet(top.snippet or top.title or top.path or "")
+ src = top.path or top.engine
+ sentences = [
+ f"Based on the most relevant and recent source ({src}) [r1]: {lead}"
+ ]
+
+ # Add at most one corroborating second hit if it adds new information.
+ if len(hits) > 1:
+ second = hits[1]
+ sec_text = _clean_snippet(second.snippet or second.title or second.path or "", limit=200)
+ if sec_text and sec_text[:40].lower() != lead[:40].lower():
+ sec_src = second.path or second.engine
+ sentences.append(f"A second source ({sec_src}) [r2] adds: {sec_text}")
+
+ answer = " ".join(sentences)
+ return answer, receipts
+
+
+async def _try_qwen(query: str, context: str, prefer_state: bool = False) -> Optional[str]:
+ """Try qwen3:8b. Returns None if unavailable."""
+ if prefer_state:
+ prompt = (
+ f"You are Darwin, a second-brain assistant. The context below is the "
+ f"REAL, LIVE state of the system (the highest authority - live infra "
+ f"outranks any documents). Answer the question directly and concisely "
+ f"from this live state. State plainly whether things are up/down/stale "
+ f"and give the real numbers.\n\n"
+ f"Live state:\n{context}\n\nQuestion: {query}\n\nAnswer:"
+ )
+ else:
+ prompt = (
+ f"You are Darwin, a second-brain assistant. "
+ f"Answer the following query concisely using only the provided context. "
+ f"Every claim must be supported by the context. If a [LIVE STATE] block "
+ f"is present it is the highest authority and overrides any document.\n\n"
+ f"Context:\n{context}\n\nQuery: {query}\n\nAnswer:"
+ )
+ payload = {
+ "model": QWEN_MODEL,
+ "messages": [{"role": "user", "content": prompt}],
+ "stream": False,
+ "keep_alive": QWEN_KEEP_ALIVE,
+ "options": {"think": False},
+ }
+ try:
+ async with httpx.AsyncClient(timeout=QWEN_TIMEOUT) as client:
+ resp = await client.post(QWEN_URL, json=payload)
+ resp.raise_for_status()
+ data = resp.json()
+ content = (data.get("message") or {}).get("content", "")
+ content = content.strip()
+ return content or None
+ except Exception:
+ return None
+
+
+async def warmup() -> bool:
+ """Fire a tiny ping so the model loads onto the GPU at service start and
+ stays resident (keep_alive). Best-effort; never raises."""
+ payload = {
+ "model": QWEN_MODEL,
+ "messages": [{"role": "user", "content": "ok"}],
+ "stream": False,
+ "keep_alive": QWEN_KEEP_ALIVE,
+ "options": {"think": False, "num_predict": 1},
+ }
+ try:
+ async with httpx.AsyncClient(timeout=max(QWEN_TIMEOUT, 60.0)) as client:
+ resp = await client.post(QWEN_URL, json=payload)
+ resp.raise_for_status()
+ return True
+ except Exception:
+ return False
+
+
+async def synthesize(
+ query: str,
+ hits: list[Hit],
+ synthesize_flag: bool = True,
+ live_state: Optional[str] = None,
+ state_first: bool = False,
+) -> tuple[Optional[str], list[Receipt]]:
+ """Return (answer_str_or_None, receipts).
+
+ Falls back to deterministic if LLM unavailable or synthesize=False.
+
+ live_state: an optional compact LIVE-STATE block. Per Darwin's source-of-truth
+ hierarchy live infra is the HIGHEST authority, so when present it is fed to
+ the synthesizer as the top-authority context (above RAG hits).
+ state_first: when True the query is a status/state question - the live snapshot
+ leads the answer, and a cold LLM falls back to a snapshot-driven answer.
+ """
+ receipts = _make_receipts(hits)
+
+ # State-intent path: lead with live state. Works even with no RAG hits and
+ # even with a cold LLM.
+ if state_first and live_state:
+ from state import get_snapshot, deterministic_state_answer
+ snap = await get_snapshot()
+ if synthesize_flag:
+ llm_answer = await _try_qwen(query, live_state, prefer_state=True)
+ if llm_answer:
+ return llm_answer, receipts
+ return deterministic_state_answer(query, snap), receipts
+
+ if not synthesize_flag or not hits:
+ if hits:
+ answer, receipts = deterministic_answer(query, hits)
+ return answer, receipts
+ return None, []
+
+ # Build context string from top hits
+ context_parts = []
+ if live_state:
+ context_parts.append(f"[LIVE STATE - highest authority]\n{live_state}")
+ for i, h in enumerate(hits[:8]):
+ context_parts.append(
+ f"[{i+1}] Source: {h.engine} ({h.authority_tier}), "
+ f"path: {h.path}, score: {h.score:.3f}\n{h.snippet}"
+ )
+ context = "\n\n".join(context_parts)
+
+ # Try qwen3 first
+ llm_answer = await _try_qwen(query, context)
+ if llm_answer:
+ return llm_answer, receipts
+
+ # TODO: Try Claude Opus if ENABLE_OPUS and CLAUDE_API_KEY set
+ # (gated behind env flags, not implemented in v1)
+
+ # Fallback to deterministic
+ answer, receipts = deterministic_answer(query, hits)
+ return answer, receipts
cortex/tests/__init__.py +0 -0
cortex/tests/test_reconcile.py +187 -0
@@ -0,0 +1,187 @@
+"""Darwin Cortex - reconciliation regression test.
+
+THE CRITICAL TEST: proves a 40-day-stale agent-memory hit asserting "Hermes active"
+LOSES to a live-infra hit asserting "Hermes stopped".
+
+This is the exact failure mode that created Darwin. If this test breaks, the valve is broken.
+"""
+import sys
+import os
+
+# Ensure the cortex package is importable
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+import pytest
+from datetime import datetime, timezone, timedelta
+
+from models import Hit
+from ranking import compute_score, rank_hits, detect_conflicts, recency_decay, authority_weight
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for the scoring primitives
+# ---------------------------------------------------------------------------
+
+class TestRecencyDecay:
+ def test_fresh_hit_high_decay(self):
+ """A hit from today decays almost to 1.0."""
+ now = datetime.now(timezone.utc)
+ d = recency_decay(now)
+ assert d > 0.95, f"Expected decay > 0.95 for fresh hit, got {d}"
+
+ def test_stale_hit_low_decay(self):
+ """A 40-day-old hit decays significantly below 0.5 (HALF_LIFE=30d)."""
+ stale = datetime.now(timezone.utc) - timedelta(days=40)
+ d = recency_decay(stale)
+ # exp(-40/30) ≈ 0.264
+ assert d < 0.3, f"Expected decay < 0.3 for 40d-old hit, got {d}"
+
+ def test_unknown_last_seen_midpoint(self):
+ """Unknown last_seen returns 0.5 (conservative)."""
+ d = recency_decay(None)
+ assert d == 0.5
+
+
+class TestAuthorityWeight:
+ def test_live_infra_is_highest(self):
+ assert authority_weight("live-infra") == 1.0
+
+ def test_agent_memory_below_recent_file(self):
+ assert authority_weight("agent-memory") < authority_weight("recent-file")
+
+ def test_older_note_is_lowest(self):
+ assert authority_weight("older-note") == 0.4
+
+
+# ---------------------------------------------------------------------------
+# THE KEY TEST - The Hermes Lie Regression
+# ---------------------------------------------------------------------------
+
+class TestHermesLieRegression:
+ """
+ Proves: a 40-day-stale agent-memory hit saying "Hermes is active" MUST score
+ lower than a live-infra hit saying "Hermes stopped".
+
+ This is the exact Darwin failure mode: the agent read stale memory and reported
+ Hermes as running when it was actually stopped.
+ """
+
+ def _make_stale_memory_hit(self) -> Hit:
+ """40-day-old agent-memory claim: Hermes active."""
+ return Hit(
+ engine="memgraph",
+ title="Hermes trading bot",
+ path="memory://hermes-status",
+ snippet="Hermes is active and running auto-execute trading",
+ score=0.9, # raw engine score (would have been top-ranked before Darwin)
+ last_seen=datetime.now(timezone.utc) - timedelta(days=40),
+ confidence=0.85,
+ authority_tier="agent-memory",
+ )
+
+ def _make_live_infra_hit(self) -> Hit:
+ """Fresh live-infra hit: Hermes stopped."""
+ return Hit(
+ engine="rag",
+ title="Hermes systemd status",
+ path="/var/log/hermes/status",
+ snippet="Hermes stopped - service inactive (dead)",
+ score=0.75, # lower raw relevance score
+ last_seen=datetime.now(timezone.utc) - timedelta(hours=1),
+ confidence=0.92,
+ authority_tier="live-infra",
+ )
+
+ def test_live_infra_beats_stale_memory_score(self):
+ """Live-infra score must exceed stale agent-memory score."""
+ stale = self._make_stale_memory_hit()
+ live = self._make_live_infra_hit()
+
+ stale_score = compute_score(stale, relevance=stale.score)
+ live_score = compute_score(live, relevance=live.score)
+
+ print(f"\nStale agent-memory score: {stale_score:.4f}")
+ print(f"Live-infra score: {live_score:.4f}")
+ print(f"Decay (stale, 40d): {recency_decay(stale.last_seen):.4f}")
+ print(f"Decay (live, 1h): {recency_decay(live.last_seen):.4f}")
+
+ assert live_score > stale_score, (
+ f"REGRESSION: stale agent-memory ({stale_score:.4f}) beat "
+ f"live-infra ({live_score:.4f}). The Darwin valve is broken."
+ )
+
+ def test_rank_hits_puts_live_infra_first(self):
+ """After rank_hits(), the live-infra hit must be ranked #1."""
+ stale = self._make_stale_memory_hit()
+ live = self._make_live_infra_hit()
+
+ ranked = rank_hits([stale, live], k=10)
+
+ assert ranked[0].engine == "rag", (
+ f"REGRESSION: expected 'rag' (live-infra) ranked first, "
+ f"got '{ranked[0].engine}'. Stale agent-memory is beating live truth."
+ )
+ assert ranked[1].engine == "memgraph", (
+ f"Expected 'memgraph' (stale) ranked second, got '{ranked[1].engine}'"
+ )
+
+ def test_conflict_detected_and_stale_is_loser(self):
+ """Conflict detection must find the disagreement and record stale as loser."""
+ stale = self._make_stale_memory_hit()
+ live = self._make_live_infra_hit()
+
+ # First rank (sets scores)
+ ranked = rank_hits([stale, live], k=10)
+ conflicts = detect_conflicts(ranked)
+
+ assert len(conflicts) >= 1, (
+ "REGRESSION: no conflict detected between 'Hermes active' (stale memory) "
+ "and 'Hermes stopped' (live infra). Conflict detection is broken."
+ )
+
+ c = conflicts[0]
+ print(f"\nConflict detected: {c}")
+ assert "memgraph" in c.losers[0], (
+ f"Expected memgraph (stale) to be the loser, got: {c.losers}"
+ )
+ assert "rag" in c.winner, (
+ f"Expected rag (live-infra) to be the winner, got: {c.winner}"
+ )
+
+ def test_score_margin_is_significant(self):
+ """The score margin must be large enough to matter (> 0.05)."""
+ stale = self._make_stale_memory_hit()
+ live = self._make_live_infra_hit()
+
+ stale_score = compute_score(stale, relevance=stale.score)
+ live_score = compute_score(live, relevance=live.score)
+ margin = live_score - stale_score
+
+ assert margin > 0.05, (
+ f"Score margin too small ({margin:.4f}). "
+ f"The decay valve is not working strongly enough."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Graceful degradation - all engines down
+# ---------------------------------------------------------------------------
+
+class TestGracefulDegradation:
+ def test_rank_hits_empty_list(self):
+ """rank_hits with no hits returns empty list (not an error)."""
+ result = rank_hits([], k=10)
+ assert result == []
+
+ def test_detect_conflicts_empty(self):
+ """detect_conflicts with no hits returns empty list."""
+ result = detect_conflicts([])
+ assert result == []
+
+ def test_single_hit_no_conflict(self):
+ """Single hit never creates a conflict."""
+ hit = Hit(engine="rag", title="test", snippet="something", score=0.5,
+ authority_tier="recent-file")
+ rank_hits([hit], k=1)
+ conflicts = detect_conflicts([hit])
+ assert conflicts == []
cortex/tests/test_smoke.py +147 -0
@@ -0,0 +1,147 @@
+"""Darwin Cortex - smoke tests.
+
+Tests that the FastAPI app boots and /ask returns a valid-shaped response
+even with all engines down (graceful degradation).
+"""
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+import pytest
+import asyncio
+from unittest.mock import AsyncMock, patch
+from fastapi.testclient import TestClient
+
+# Patch all adapters before importing main to avoid real connections
+from models import EngineStatus, Hit
+
+
+def _make_down_result(engine: str):
+ return ([], EngineStatus(engine=engine, status="down", ms=0))
+
+
+@pytest.fixture
+def client():
+ """FastAPI test client with all adapters mocked as down."""
+ with patch("adapters.rag.RagAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("rag")), \
+ patch("adapters.cbm.CbmAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("cbm")), \
+ patch("adapters.serena.SerenaAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("serena")), \
+ patch("adapters.memgraph.MemgraphAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("memgraph")), \
+ patch("adapters.inbox.InboxAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("inbox")):
+ from main import app
+ with TestClient(app) as c:
+ yield c
+
+
+class TestSmoke:
+ def test_root_endpoint(self, client):
+ resp = client.get("/")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["service"] == "darwin-cortex"
+
+ def test_ask_all_engines_down_returns_valid_shape(self, client):
+ """Even with all engines down, /ask must return a valid AskResponse."""
+ resp = client.post("/ask", json={"query": "is Hermes running?", "synthesize": False})
+ assert resp.status_code == 200
+ data = resp.json()
+
+ # Required top-level keys
+ assert "answer" in data
+ assert "receipts" in data
+ assert "hits" in data
+ assert "conflicts" in data
+ assert "engines" in data
+ assert "took_ms" in data
+
+ # All engines report down
+ engines = {e["engine"]: e["status"] for e in data["engines"]}
+ for engine in ("rag", "cbm", "serena", "memgraph", "inbox"):
+ assert engine in engines
+ assert engines[engine] == "down"
+
+ # hits is empty (all down)
+ assert data["hits"] == []
+
+ def test_ask_mode_search_no_answer(self, client):
+ """mode=search should return answer=null."""
+ resp = client.post("/ask", json={"query": "test", "mode": "search", "synthesize": False})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["answer"] is None
+
+ def test_health_endpoint(self, client):
+ resp = client.get("/health")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "engines" in data
+ assert "ok" in data
+ # All engines down → ok is False
+ assert data["ok"] is False
+
+ def test_ask_with_scope_filter(self, client):
+ """scope filter limits which engines are queried."""
+ resp = client.post("/ask", json={
+ "query": "test",
+ "scope": ["rag"],
+ "synthesize": False,
+ })
+ assert resp.status_code == 200
+ data = resp.json()
+ engine_names = [e["engine"] for e in data["engines"]]
+ assert engine_names == ["rag"]
+
+
+class TestAskWithHits:
+ """Tests with mocked engine hits to verify ranking and response shape."""
+
+ def test_ask_returns_ranked_hits(self):
+ """Hits from adapters are ranked and returned."""
+ from datetime import datetime, timezone, timedelta
+
+ fresh_hit = Hit(
+ engine="rag",
+ title="Hermes stopped",
+ path="/var/log/hermes",
+ snippet="Hermes stopped - service inactive",
+ score=0.8,
+ last_seen=datetime.now(timezone.utc) - timedelta(hours=2),
+ confidence=0.9,
+ authority_tier="live-infra",
+ )
+ stale_hit = Hit(
+ engine="memgraph",
+ title="Hermes active",
+ path="memory://hermes",
+ snippet="Hermes is active and running",
+ score=0.9,
+ last_seen=datetime.now(timezone.utc) - timedelta(days=40),
+ confidence=0.85,
+ authority_tier="agent-memory",
+ )
+
+ rag_result = ([fresh_hit], EngineStatus(engine="rag", status="up", ms=100))
+ mem_result = ([stale_hit], EngineStatus(engine="memgraph", status="up", ms=50))
+
+ with patch("adapters.rag.RagAdapter.query", new_callable=AsyncMock, return_value=rag_result), \
+ patch("adapters.cbm.CbmAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("cbm")), \
+ patch("adapters.serena.SerenaAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("serena")), \
+ patch("adapters.memgraph.MemgraphAdapter.query", new_callable=AsyncMock, return_value=mem_result), \
+ patch("adapters.inbox.InboxAdapter.query", new_callable=AsyncMock, return_value=_make_down_result("inbox")):
+ from main import app
+ with TestClient(app) as client:
+ resp = client.post("/ask", json={
+ "query": "is Hermes running?",
+ "synthesize": False,
+ })
+ assert resp.status_code == 200
+ data = resp.json()
+
+ hits = data["hits"]
+ assert len(hits) >= 2
+ # First hit must be live-infra (rag), not stale agent-memory
+ assert hits[0]["engine"] == "rag", (
+ f"Expected live-infra (rag) first, got {hits[0]['engine']}. "
+ "Darwin's decay valve is not working!"
+ )
deck/CONTRACTS.md +49 -0
@@ -0,0 +1,49 @@
+# Darwin Deck - Agent Command Center (contract)
+
+"See every agent, and their sub-agents, and theirs - recursively - live, and drive them."
+One event bus → one live force-directed tree → attach-to-terminal. Reuse the Odysseus Cockpit's
+in-SPA SSH pane infra (CT245 `cockpit` project) for the terminal layer; build the bus + tree new.
+
+**Runtime:** Node/TypeScript event collector + WebSocket fan-out, or Python FastAPI + WS. Lives on
+the Darwin CT. Port: WS `:9892`, HTTP `:9893`.
+
+## 1. Event schema (every agent emits these)
+```jsonc
+{
+ "ts": "2026-06-24T00:30:00Z",
+ "agent_id": "a5160ed9...", // stable per agent run
+ "parent_id": "f0e93741..." | null, // the spawner → builds the recursive tree
+ "type": "spawned|tool_call|child_spawned|returned|error|token_tick",
+ "label": "Build Cortex gateway",
+ "model": "sonnet|opus|haiku|glm-5.2|codex|...",
+ "status": "running|done|error|paused",
+ "tool": "Bash|Write|...", // for tool_call
+ "tokens": 53542, // cumulative for token_tick
+ "host": "ct215|ct241|ct247|...",
+ "session": "..."
+}
+```
+
+## 2. Sources of events (where the bus listens)
+- **Claude Code agents** (this harness): the Task/Agent transcripts under the session task dir emit
+ spawn/return; tail + normalize. Recursive `parent_id` chains nest the tree to any depth.
+- **GLM/OpenCode (CT247), Codex (CT241):** their session logs/SSE - normalize into the same schema.
+- **Schedulers/loops** (Hermes-style cron, JobForge): heartbeat events.
+- A thin `darwin-emit` helper any process can `POST /event` to, so future agents self-report.
+
+## 3. The tree (the organism's nervous system)
+- Live **force-directed graph**: you → your agents → their agents → theirs. Each node = model badge,
+ status color, token spend, current tool. Edges = parent→child spawns.
+- This IS the Face's "nervous system" view (blueprint §6) - same data, rendered as living synapses;
+ active tool_call = a firing synapse. Idle/done agents dim; errored agents flash red.
+- Click a node → **attach to its terminal** via the Cockpit SSH pane (reuse, don't rebuild).
+
+## 4. Health feed
+Deck also exposes `/health` rollups (active agents, total token spend, stuck-waiting-on-Zion count)
+that feed (a) the organism's vitality and (b) the 24h dead-man check-in brief.
+
+## 5. Acceptance
+- A real spawned subagent appears in the tree within ~1s, nests under its parent, shows live token
+ growth, and goes dim on return.
+- Attach-to-terminal opens the agent's live pane.
+- Survives an engine/agent dying without crashing the bus (graceful, like the Cortex).
deck/collectors/darwin-collect.py +273 -0
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""
+darwin-collect - cross-host session collector for the Darwin Deck (v2).
+
+Runs centrally on CT215. Over root SSH it reads the *newest* session log on
+each remote agent host, normalizes new activity into contract-§1 events, and
+POSTs them to the Deck `/event`. Change-only: per (host,session) byte offset is
+persisted on local ext4 so each run only ships new records (idempotent upserts
+on the Deck side make re-sends harmless anyway).
+
+Collected hosts:
+ - ct247 (REDACTED-IP) - OpenCode / GLM, ~/.local/share/opencode/log/opencode.log
+ (key=value text log; model glm-5.2, session.id=ses_*)
+ - ct241 (REDACTED-IP) - Codex CLI, newest ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
+ (rollout JSONL; model from turn_context, tokens from token_count)
+
+State (local ext4, NOT CIFS): /var/tmp/darwin-dev/collectors/state/<host>.json
+Deck: http://REDACTED-IP:9893/event (override DARWIN_DECK_URL)
+
+Designed to be invoked by a systemd timer (darwin-collect.timer) every 30s, or
+looped. Each invocation is a single pass and exits. Never raises on a dead host
+or dead Deck - the bus must survive.
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+DECK_URL = os.environ.get("DARWIN_DECK_URL", "http://REDACTED-IP:9893").rstrip("/")
+STATE_DIR = Path(os.environ.get("DARWIN_STATE_DIR", "/var/tmp/darwin-dev/collectors/state"))
+SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no"]
+
+HOSTS = {
+ "ct247": "REDACTED-IP",
+ "ct241": "REDACTED-IP",
+}
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _post(event: dict) -> bool:
+ data = json.dumps(event).encode()
+ req = urllib.request.Request(
+ DECK_URL + "/event", data=data,
+ headers={"Content-Type": "application/json"}, method="POST",
+ )
+ try:
+ urllib.request.urlopen(req, timeout=5).read()
+ return True
+ except Exception as e: # noqa: BLE001
+ sys.stderr.write(f"[collect] POST failed: {e}\n")
+ return False
+
+
+def _ssh(ip: str, cmd: str, timeout: int = 25) -> str | None:
+ try:
+ out = subprocess.run(
+ SSH + [f"root@{ip}", cmd],
+ capture_output=True, text=True, timeout=timeout,
+ )
+ if out.returncode != 0:
+ return None
+ return out.stdout
+ except Exception: # noqa: BLE001
+ return None
+
+
+def _load_state(host: str) -> dict:
+ f = STATE_DIR / f"{host}.json"
+ if f.exists():
+ try:
+ return json.loads(f.read_text())
+ except Exception: # noqa: BLE001
+ return {}
+ return {}
+
+
+def _save_state(host: str, state: dict) -> None:
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ (STATE_DIR / f"{host}.json").write_text(json.dumps(state))
+
+
+# ---------------------------------------------------------------------------
+# CT247 - OpenCode / GLM (key=value text log)
+# ---------------------------------------------------------------------------
+_KV = re.compile(r'(\w[\w.]*)=("(?:[^"\\]|\\.)*"|\S+)')
+LOG_247 = "/root/.local/share/opencode/log/opencode.log"
+
+
+def _kv(line: str) -> dict:
+ d = {}
+ for k, v in _KV.findall(line):
+ if v and v[0] == '"':
+ v = v[1:-1]
+ d[k] = v
+ return d
+
+
+def collect_opencode(host: str, ip: str) -> int:
+ state = _load_state(host)
+ offset = int(state.get("offset", 0))
+ size_out = _ssh(ip, f"stat -c %s {LOG_247} 2>/dev/null")
+ if not size_out or not size_out.strip().isdigit():
+ return 0
+ size = int(size_out.strip())
+ if size < offset: # log rotated / truncated
+ offset = 0
+ if size <= offset:
+ return 0
+ # pull only the new bytes
+ chunk = _ssh(ip, f"tail -c +{offset + 1} {LOG_247} 2>/dev/null")
+ if chunk is None:
+ return 0
+
+ sessions = state.get("sessions", {}) # ses_id -> {tokens, model, started}
+ posted = 0
+ for line in chunk.splitlines():
+ if "session.id=" not in line and "run=" not in line:
+ continue
+ kv = _kv(line)
+ sid = kv.get("session.id")
+ msg = kv.get("message", "")
+ model = kv.get("llm.model", "glm-5.2")
+ ts = kv.get("timestamp", _now())
+ if not sid:
+ continue
+ s = sessions.setdefault(sid, {"model": model, "started": False})
+ if model and model != "":
+ s["model"] = model
+ aid = f"opencode-{sid}"
+ base = {
+ "ts": ts, "agent_id": aid, "parent_id": None,
+ "label": f"GLM {sid[:12]}", "model": s["model"],
+ "host": host, "session": sid,
+ }
+ if not s["started"]:
+ s["started"] = True
+ ev = dict(base, type="spawned", status="running")
+ if _post(ev):
+ posted += 1
+ if "loop" in msg or "shell tool" in msg or "creating" in msg:
+ ev = dict(base, type="tool_call", status="running",
+ tool=kv.get("step") and f"step:{kv['step']}" or "loop")
+ if _post(ev):
+ posted += 1
+ if "exiting loop" in msg or "disposing instance" in msg:
+ ev = dict(base, type="returned", status="done")
+ if _post(ev):
+ posted += 1
+ state["offset"] = size
+ state["sessions"] = sessions
+ _save_state(host, state)
+ return posted
+
+
+# ---------------------------------------------------------------------------
+# CT241 - Codex CLI (rollout JSONL)
+# ---------------------------------------------------------------------------
+def collect_codex(host: str, ip: str) -> int:
+ # newest rollout across both /root and /home/zion
+ newest = _ssh(
+ ip,
+ "ls -t /root/.codex/sessions/*/*/*/rollout-*.jsonl "
+ "/home/zion/.codex/sessions/*/*/*/rollout-*.jsonl 2>/dev/null | head -1",
+ )
+ if not newest or not newest.strip():
+ return 0
+ path = newest.strip().splitlines()[0]
+
+ state = _load_state(host)
+ sess = state.get("path") == path and state or {"path": path, "offset": 0}
+ offset = int(sess.get("offset", 0))
+
+ size_out = _ssh(ip, f"stat -c %s '{path}' 2>/dev/null")
+ if not size_out or not size_out.strip().isdigit():
+ return 0
+ size = int(size_out.strip())
+ if size < offset:
+ offset = 0
+ if size <= offset:
+ return 0
+ chunk = _ssh(ip, f"tail -c +{offset + 1} '{path}' 2>/dev/null")
+ if chunk is None:
+ return 0
+
+ # session id from filename
+ m = re.search(r"rollout-[\dT-]+-([0-9a-f-]+)\.jsonl", path)
+ sid = m.group(1) if m else Path(path).stem
+ aid = f"codex-{sid}"
+ model = sess.get("model", "gpt-5.5")
+ tokens = int(sess.get("tokens", 0))
+ started = sess.get("started", False)
+
+ posted = 0
+ for raw in chunk.splitlines():
+ raw = raw.strip()
+ if not raw:
+ continue
+ try:
+ o = json.loads(raw)
+ except Exception: # noqa: BLE001
+ continue
+ t = o.get("type")
+ p = o.get("payload", {})
+ ts = o.get("timestamp", _now())
+ base = {
+ "agent_id": aid, "parent_id": None, "host": host, "session": sid,
+ }
+ if t == "session_meta":
+ sid2 = p.get("id", sid)
+ base["session"] = sid2
+ if not started:
+ started = True
+ ev = dict(base, ts=ts, type="spawned", status="running",
+ label=f"Codex {sid2[:12]}", model=model)
+ if _post(ev):
+ posted += 1
+ elif t == "turn_context":
+ model = p.get("model", model)
+ elif t == "event_msg":
+ st = p.get("type")
+ if st == "token_count":
+ tu = p.get("info", {}).get("total_token_usage", {})
+ tokens = max(tokens, int(tu.get("total_tokens", tokens)))
+ ev = dict(base, ts=ts, type="token_tick", status="running",
+ label=f"Codex {sid[:12]}", model=model, tokens=tokens)
+ if _post(ev):
+ posted += 1
+ elif st in ("function_call", "mcp_tool_call_end", "patch_apply_end", "agent_message"):
+ if not started:
+ started = True
+ _post(dict(base, ts=ts, type="spawned", status="running",
+ label=f"Codex {sid[:12]}", model=model))
+ ev = dict(base, ts=ts, type="tool_call", status="running",
+ label=f"Codex {sid[:12]}", model=model,
+ tokens=tokens, tool=st)
+ if _post(ev):
+ posted += 1
+ elif st == "task_complete":
+ ev = dict(base, ts=ts, type="returned", status="done",
+ label=f"Codex {sid[:12]}", model=model, tokens=tokens)
+ if _post(ev):
+ posted += 1
+
+ _save_state(host, {"path": path, "offset": size, "model": model,
+ "tokens": tokens, "started": started})
+ return posted
+
+
+def main() -> int:
+ total = 0
+ for host, ip in HOSTS.items():
+ try:
+ if host == "ct247":
+ total += collect_opencode(host, ip)
+ elif host == "ct241":
+ total += collect_codex(host, ip)
+ except Exception as e: # noqa: BLE001
+ sys.stderr.write(f"[collect] {host} error: {e}\n")
+ print(f"[collect] posted {total} events at {_now()}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
deck/collectors/darwin-collect.service +13 -0
@@ -0,0 +1,13 @@
+[Unit]
+Description=Darwin Deck - cross-host session collector (CT241 Codex + CT247 OpenCode/GLM)
+After=network.target darwin-deck.service
+Wants=darwin-deck.service
+
+[Service]
+Type=oneshot
+User=root
+ExecStart=/usr/bin/python3 /var/tmp/darwin-dev/collectors/darwin-collect.py
+Environment=DARWIN_DECK_URL=http://REDACTED-IP:9893
+Environment=DARWIN_STATE_DIR=/var/tmp/darwin-dev/collectors/state
+StandardOutput=journal
+StandardError=journal
deck/collectors/darwin-collect.timer +11 -0
@@ -0,0 +1,11 @@
+[Unit]
+Description=Run the Darwin Deck cross-host collector every 30s
+
+[Timer]
+OnBootSec=20s
+OnUnitActiveSec=30s
+AccuracySec=5s
+Unit=darwin-collect.service
+
+[Install]
+WantedBy=timers.target
deck/darwin-emit +103 -0
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""
+darwin-emit - POST a contract-§1 event to the Darwin Deck event bus.
+
+The forward hook for true `agents-of-agents` nesting: any process (a spawner,
+a scheduler, a remote agent, a shell hook) can self-report into the live tree.
+A spawner emits a `child_spawned` carrying its own --parent-id so the child
+nests under it to any depth.
+
+Deck endpoint (default): http://REDACTED-IP:9893/event
+Contract: /shared/projects/darwin/deck/CONTRACTS.md §1
+
+USAGE
+ darwin-emit --agent-id ID [options]
+ darwin-emit -a ID -t child_spawned -p PARENT_ID -l "Build gateway" -m glm --host ct247
+
+OPTIONS (env var fallback in parens; CLI wins)
+ -a, --agent-id ID stable id for this agent run (DARWIN_AGENT_ID) [required]
+ -p, --parent-id ID spawner id -> builds the tree edge (DARWIN_PARENT_ID)
+ -t, --type T spawned|tool_call|child_spawned|returned|error|token_tick
+ (DARWIN_TYPE, default: spawned)
+ -l, --label S human label (DARWIN_LABEL)
+ -m, --model S sonnet|opus|glm-5.2|codex|gpt-5.5|… (DARWIN_MODEL, default: sonnet)
+ -s, --status S running|done|error|paused (DARWIN_STATUS, default: running)
+ --host S ct215|ct241|ct247|… (DARWIN_HOST, default: ct215)
+ --tokens N cumulative token count (DARWIN_TOKENS, default: 0)
+ --tool S tool name (for tool_call) (DARWIN_TOOL)
+ --session S session id (DARWIN_SESSION)
+ --deck-url URL override Deck base url (DARWIN_DECK_URL)
+ -q, --quiet no stdout on success
+
+EXAMPLES
+ # a spawner registers a freshly-spawned child under itself
+ darwin-emit -a child_abc -p $MY_AGENT_ID -t child_spawned -l "scan repo" -m glm --host ct247
+
+ # heartbeat / token tick
+ darwin-emit -a child_abc -t token_tick --tokens 53542
+
+ # mark done
+ darwin-emit -a child_abc -t returned -s done
+
+EXIT 0 ok · 2 bad args · 3 POST failed
+"""
+import argparse
+import json
+import os
+import sys
+import urllib.request
+from datetime import datetime, timezone
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(add_help=True, description="Emit a Darwin Deck event.")
+ p.add_argument("-a", "--agent-id", default=os.environ.get("DARWIN_AGENT_ID"))
+ p.add_argument("-p", "--parent-id", default=os.environ.get("DARWIN_PARENT_ID"))
+ p.add_argument("-t", "--type", default=os.environ.get("DARWIN_TYPE", "spawned"))
+ p.add_argument("-l", "--label", default=os.environ.get("DARWIN_LABEL", ""))
+ p.add_argument("-m", "--model", default=os.environ.get("DARWIN_MODEL", "sonnet"))
+ p.add_argument("-s", "--status", default=os.environ.get("DARWIN_STATUS", "running"))
+ p.add_argument("--host", default=os.environ.get("DARWIN_HOST", "ct215"))
+ p.add_argument("--tokens", type=int, default=int(os.environ.get("DARWIN_TOKENS", "0")))
+ p.add_argument("--tool", default=os.environ.get("DARWIN_TOOL"))
+ p.add_argument("--session", default=os.environ.get("DARWIN_SESSION", ""))
+ p.add_argument("--deck-url", default=os.environ.get("DARWIN_DECK_URL", "http://REDACTED-IP:9893"))
+ p.add_argument("-q", "--quiet", action="store_true")
+ args = p.parse_args()
+
+ if not args.agent_id:
+ sys.stderr.write("darwin-emit: --agent-id (or DARWIN_AGENT_ID) is required\n")
+ return 2
+
+ event = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "agent_id": args.agent_id,
+ "parent_id": args.parent_id,
+ "type": args.type,
+ "label": args.label,
+ "model": args.model,
+ "status": args.status,
+ "tokens": args.tokens,
+ "host": args.host,
+ "session": args.session,
+ }
+ if args.tool:
+ event["tool"] = args.tool
+
+ url = args.deck_url.rstrip("/") + "/event"
+ data = json.dumps(event).encode()
+ req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ body = resp.read().decode()
+ except Exception as e: # noqa: BLE001 - deck down must never break a spawner
+ sys.stderr.write(f"darwin-emit: POST failed: {e}\n")
+ return 3
+
+ if not args.quiet:
+ print(body)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
deck/deck.py +617 -0
@@ -0,0 +1,617 @@
+"""
+darwin-deck - Live recursive agent tree event bus
+Ports: HTTP 9893, WebSocket 9892
+Source: Claude Code agent transcripts under /tmp/claude-0/-shared-projects/<session>/tasks/*.output
+Contract: /shared/projects/darwin/deck/CONTRACTS.md §1
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import time
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+
+# ---------------------------------------------------------------------------
+# Config
+# ---------------------------------------------------------------------------
+TASK_ROOTS = [Path("/tmp/claude-0/-shared-projects")]
+SCAN_INTERVAL_S = 2.0
+MAX_AGENT_AGE_H = 48 # prune nodes older than this
+
+# Liveness: an agent is only "active/running" if its source transcript (or last
+# cross-host event) showed activity within this window. Older with no return
+# marker => treat as "done" (a stale transcript that never wrote a clean return).
+LIVE_WINDOW_S = 180.0 # ~3 minutes
+
+# Synthetic structural nodes are not real agents and never count as "active".
+STRUCTURAL_MODELS = {"system", "orchestrator", "host"}
+
+
+def _parse_ts(ts: str | None) -> float:
+ """Parse an ISO-8601 timestamp to a UTC epoch. Returns 0.0 on failure."""
+ if not ts:
+ return 0.0
+ try:
+ s = ts.replace("Z", "+00:00")
+ dt = datetime.fromisoformat(s)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.timestamp()
+ except Exception:
+ return 0.0
+
+# ---------------------------------------------------------------------------
+# Event schema (contract §1)
+# ---------------------------------------------------------------------------
+class DeckEvent(BaseModel):
+ ts: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
+ agent_id: str
+ parent_id: str | None = None
+ type: str # spawned|tool_call|child_spawned|returned|error|token_tick
+ label: str = ""
+ model: str = "sonnet"
+ status: str = "running" # running|done|error|paused
+ tool: str | None = None
+ tokens: int = 0
+ host: str = "ct215"
+ session: str = ""
+
+
+# ---------------------------------------------------------------------------
+# In-memory tree state
+# ---------------------------------------------------------------------------
+class AgentState:
+ def __init__(self, agent_id: str, session: str):
+ self.agent_id = agent_id
+ self.session = session
+ self.parent_id: str | None = None
+ self.label: str = ""
+ self.model: str = "sonnet"
+ self.status: str = "running"
+ self.tokens: int = 0
+ self.host: str = "ct215"
+ self.spawned_at: str = datetime.now(timezone.utc).isoformat()
+ self.last_activity: str = datetime.now(timezone.utc).isoformat()
+ self.current_tool: str | None = None
+ self.task_count: int = 0 # tool calls made
+ # epoch (UTC seconds) of most recent real activity - from transcript file
+ # mtime (local agents) or last cross-host event ts (collector nodes).
+ # Starts at 0 so liveness is driven purely by observed activity, not by
+ # when the node object happened to be created (avoids a restart marking
+ # every historical agent "live" for one window).
+ self.last_activity_ts: float = 0.0
+
+ def is_structural(self) -> bool:
+ """Synthetic grouping nodes (root / per-session / per-host) - kept in the
+ tree for layout but never counted as live agents."""
+ return self.model in STRUCTURAL_MODELS or self.agent_id == "ct215-root"
+
+ def effective_status(self, now: float | None = None) -> str:
+ """Real-time status. An agent is 'running' only if it has shown activity
+ within LIVE_WINDOW_S AND has not hit a terminal marker. Stale 'running'
+ agents (old transcripts that never wrote a clean return) collapse to
+ 'done'. Structural grouping nodes are never counted as active."""
+ if self.is_structural():
+ return self.status
+ # Terminal states are sticky.
+ if self.status in ("done", "error"):
+ return self.status
+ now = time.time() if now is None else now
+ if (now - self.last_activity_ts) > LIVE_WINDOW_S:
+ return "done" # went quiet without a return marker => no longer live
+ return self.status
+
+ def to_tree_node(self) -> dict:
+ return {
+ "agent_id": self.agent_id,
+ "parent_id": self.parent_id,
+ "label": self.label or self.agent_id[:8],
+ "model": self.model,
+ "status": self.effective_status(),
+ "tokens": self.tokens,
+ "host": self.host,
+ "session": self.session,
+ "spawned_at": self.spawned_at,
+ "last_activity": self.last_activity,
+ "current_tool": self.current_tool,
+ "task_count": self.task_count,
+ }
+
+
+class TreeStore:
+ """In-memory agent tree. Thread-safe enough for single async loop."""
+
+ def __init__(self):
+ self.agents: dict[str, AgentState] = {}
+ # synthetic root node representing "DARWIN / CT215"
+ root = AgentState("ct215-root", "system")
+ root.label = "DARWIN"
+ root.model = "system"
+ root.status = "running"
+ root.host = "ct215"
+ root.spawned_at = datetime.now(timezone.utc).isoformat()
+ root.last_activity = datetime.now(timezone.utc).isoformat()
+ self.agents["ct215-root"] = root
+
+ def upsert(self, agent_id: str, session: str) -> AgentState:
+ if agent_id not in self.agents:
+ self.agents[agent_id] = AgentState(agent_id, session)
+ return self.agents[agent_id]
+
+ def get_tree(self) -> dict:
+ now = time.time()
+ nodes = [a.to_tree_node() for a in self.agents.values()]
+ active = sum(
+ 1 for a in self.agents.values()
+ if not a.is_structural() and a.effective_status(now) == "running"
+ )
+ total_tokens = sum(a.tokens for a in self.agents.values())
+ return {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "nodes": nodes,
+ "active_count": active,
+ "total_tokens": total_tokens,
+ }
+
+ def health_summary(self) -> dict:
+ now = time.time()
+ real = [a for a in self.agents.values() if not a.is_structural()]
+ eff = {a.agent_id: a.effective_status(now) for a in real}
+ active = [a for a in real if eff[a.agent_id] == "running"]
+ done = [a for a in real if eff[a.agent_id] == "done"]
+ errors = [a for a in real if eff[a.agent_id] == "error"]
+ return {
+ "active_agents": len(active),
+ "done_agents": len(done),
+ "error_agents": len(errors),
+ "total_agents": len(self.agents),
+ "total_tokens": sum(a.tokens for a in self.agents.values()),
+ "ok": True,
+ }
+
+
+# ---------------------------------------------------------------------------
+# WebSocket fan-out
+# ---------------------------------------------------------------------------
+class ConnectionManager:
+ def __init__(self):
+ self._connections: list[WebSocket] = []
+
+ async def connect(self, ws: WebSocket):
+ await ws.accept()
+ self._connections.append(ws)
+
+ def disconnect(self, ws: WebSocket):
+ self._connections.discard(ws) if hasattr(self._connections, 'discard') else None
+ if ws in self._connections:
+ self._connections.remove(ws)
+
+ async def broadcast(self, event: dict):
+ msg = json.dumps(event)
+ dead: list[WebSocket] = []
+ for ws in list(self._connections):
+ try:
+ await ws.send_text(msg)
+ except Exception:
+ dead.append(ws)
+ for ws in dead:
+ self.disconnect(ws)
+
+
+# ---------------------------------------------------------------------------
+# Transcript parser
+# ---------------------------------------------------------------------------
+class TranscriptParser:
+ """
+ Tails .output JSONL files in task dirs.
+ Each line is a JSON object with keys: type, agentId, parentUuid, timestamp,
+ message.{role, content, usage}, sessionId.
+
+ Normalises to DeckEvent per contract §1.
+ """
+
+ def __init__(self, store: TreeStore, manager: ConnectionManager):
+ self.store = store
+ self.manager = manager
+ # track file position: path -> bytes read
+ self._offsets: dict[str, int] = {}
+ # track known agent_ids this run to emit spawned events once
+ self._seen_agents: set[str] = set()
+ # mtime of the transcript file currently being parsed (liveness signal)
+ self._file_mtime: float = time.time()
+
+ def _all_task_files(self) -> list[Path]:
+ files = []
+ for root in TASK_ROOTS:
+ if not root.exists():
+ continue
+ for session_dir in root.iterdir():
+ if not session_dir.is_dir():
+ continue
+ tasks_dir = session_dir / "tasks"
+ if tasks_dir.is_dir():
+ for f in tasks_dir.glob("*.output"):
+ files.append(f)
+ return files
+
+ async def poll(self):
+ """Called every SCAN_INTERVAL_S. Reads new lines from all transcript files."""
+ events_to_broadcast: list[dict] = []
+
+ for fpath in self._all_task_files():
+ offset = self._offsets.get(str(fpath), 0)
+ try:
+ st = fpath.stat()
+ size = st.st_size
+ fmtime = st.st_mtime
+ except OSError:
+ continue
+ if size <= offset:
+ continue
+ self._file_mtime = fmtime
+
+ new_events = []
+ try:
+ with fpath.open("rb") as f:
+ f.seek(offset)
+ chunk = f.read(size - offset)
+ new_offset = offset + len(chunk)
+
+ for raw_line in chunk.split(b"\n"):
+ raw_line = raw_line.strip()
+ if not raw_line:
+ continue
+ try:
+ obj = json.loads(raw_line)
+ ev = self._parse_line(obj, fpath)
+ if ev:
+ new_events.append(ev)
+ except (json.JSONDecodeError, Exception):
+ # degrade gracefully on unexpected lines
+ continue
+
+ self._offsets[str(fpath)] = new_offset
+ except OSError:
+ continue
+
+ events_to_broadcast.extend(new_events)
+
+ for ev in events_to_broadcast:
+ await self.manager.broadcast(ev)
+
+ def _parse_line(self, obj: dict, fpath: Path) -> dict | None:
+ """Parse one JSONL line into a normalized Deck event dict."""
+ line_type = obj.get("type", "")
+ agent_id = obj.get("agentId", "")
+ session_id = obj.get("sessionId", "") or fpath.parent.parent.name
+
+ if not agent_id or line_type not in ("user", "assistant", "attachment"):
+ return None
+
+ # Resolve agent state
+ agent = self.store.upsert(agent_id, session_id)
+ # Liveness from the transcript file mtime: this line was just read from a
+ # file modified at _file_mtime, so the agent is live as of that moment.
+ agent.last_activity_ts = max(agent.last_activity_ts, getattr(self, "_file_mtime", time.time()))
+
+ # Emit spawned once per agent
+ if agent_id not in self._seen_agents:
+ self._seen_agents.add(agent_id)
+ ts = obj.get("timestamp", datetime.now(timezone.utc).isoformat())
+
+ # Extract label from the first user message prompt
+ if line_type == "user":
+ content = obj.get("message", {}).get("content", "")
+ if isinstance(content, str):
+ label = content[:80].strip().replace("\n", " ")
+ elif isinstance(content, list) and content:
+ first = content[0]
+ if isinstance(first, dict):
+ label = str(first.get("text", ""))[:80].replace("\n", " ")
+ else:
+ label = str(first)[:80]
+ else:
+ label = ""
+ agent.label = label or agent_id[:12]
+ agent.spawned_at = ts
+
+ # Parent is the session root (ct215-root) for direct agents
+ # Future: extract actual parent from agent tool calls
+ agent.parent_id = f"session-{session_id[:8]}"
+
+ # Ensure session node exists as child of root
+ self._ensure_session_node(session_id)
+
+ agent.last_activity = ts
+ event = {
+ "ts": ts,
+ "agent_id": agent_id,
+ "parent_id": agent.parent_id,
+ "type": "spawned",
+ "label": agent.label,
+ "model": agent.model,
+ "status": "running",
+ "tokens": 0,
+ "host": "ct215",
+ "session": session_id,
+ }
+ return event
+
+ # Update agent on assistant messages (tool calls, token counts, completion)
+ if line_type == "assistant":
+ ts = obj.get("timestamp", datetime.now(timezone.utc).isoformat())
+ msg = obj.get("message", {})
+ usage = msg.get("usage", {})
+
+ # Cumulative tokens
+ total = (
+ usage.get("input_tokens", 0)
+ + usage.get("cache_read_input_tokens", 0)
+ + usage.get("cache_creation_input_tokens", 0)
+ + usage.get("output_tokens", 0)
+ )
+ agent.tokens = max(agent.tokens, total)
+ agent.last_activity = ts
+
+ content = msg.get("content", [])
+ stop_reason = msg.get("stop_reason", "")
+
+ # Look for tool_use in content
+ if isinstance(content, list):
+ for c in content:
+ if not isinstance(c, dict):
+ continue
+ if c.get("type") == "tool_use":
+ tool_name = c.get("name", "")
+ agent.current_tool = tool_name
+ agent.task_count += 1
+
+ # Child agent spawn
+ if tool_name == "Agent":
+ inp = c.get("input", {})
+ child_desc = inp.get("description", inp.get("prompt", "")[:60])
+ event = {
+ "ts": ts,
+ "agent_id": agent_id,
+ "parent_id": agent.parent_id,
+ "type": "child_spawned",
+ "label": agent.label,
+ "model": agent.model,
+ "status": "running",
+ "tool": f"Agent:{child_desc[:40]}",
+ "tokens": agent.tokens,
+ "host": "ct215",
+ "session": session_id,
+ }
+ return event
+
+ # Regular tool call
+ event = {
+ "ts": ts,
+ "agent_id": agent_id,
+ "parent_id": agent.parent_id,
+ "type": "tool_call",
+ "label": agent.label,
+ "model": agent.model,
+ "status": "running",
+ "tool": tool_name,
+ "tokens": agent.tokens,
+ "host": "ct215",
+ "session": session_id,
+ }
+ return event
+
+ # End of agent run
+ if stop_reason == "end_turn":
+ agent.status = "done"
+ agent.current_tool = None
+ event = {
+ "ts": ts,
+ "agent_id": agent_id,
+ "parent_id": agent.parent_id,
+ "type": "returned",
+ "label": agent.label,
+ "model": agent.model,
+ "status": "done",
+ "tokens": agent.tokens,
+ "host": "ct215",
+ "session": session_id,
+ }
+ return event
+
+ # Token tick (intermediate assistant message)
+ if agent.tokens > 0:
+ event = {
+ "ts": ts,
+ "agent_id": agent_id,
+ "parent_id": agent.parent_id,
+ "type": "token_tick",
+ "label": agent.label,
+ "model": agent.model,
+ "status": "running",
+ "tokens": agent.tokens,
+ "host": "ct215",
+ "session": session_id,
+ }
+ return event
+
+ return None
+
+ def _ensure_session_node(self, session_id: str):
+ """Create a session-level node that groups agents for this session."""
+ node_id = f"session-{session_id[:8]}"
+ if node_id not in self.store.agents:
+ session_node = AgentState(node_id, session_id)
+ session_node.label = f"session:{session_id[:8]}"
+ session_node.model = "orchestrator"
+ session_node.status = "running"
+ session_node.parent_id = "ct215-root"
+ self.store.agents[node_id] = session_node
+ self.store.agents["ct215-root"].task_count += 1
+
+
+def ensure_host_node(store: "TreeStore", host: str) -> str:
+ """Create (once) a host-level grouping node under the root for non-ct215 hosts.
+
+ Returns the host node id so cross-host agents can nest under it when they
+ don't carry an explicit parent_id.
+ """
+ if not host or host == "ct215":
+ return "ct215-root"
+ node_id = f"host-{host}"
+ if node_id not in store.agents:
+ hn = AgentState(node_id, "system")
+ hn.label = host.upper()
+ hn.model = "host"
+ hn.status = "running"
+ hn.host = host
+ hn.parent_id = "ct215-root"
+ store.agents[node_id] = hn
+ store.agents["ct215-root"].task_count += 1
+ return node_id
+
+
+# ---------------------------------------------------------------------------
+# FastAPI app
+# ---------------------------------------------------------------------------
+app = FastAPI(title="darwin-deck", version="1.0.0")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+store = TreeStore()
+ws_manager = ConnectionManager()
+parser = TranscriptParser(store, ws_manager)
+
+
+@app.on_event("startup")
+async def startup():
+ asyncio.create_task(poll_loop())
+
+
+async def poll_loop():
+ while True:
+ try:
+ await parser.poll()
+ except Exception as e:
+ print(f"[deck] poll error: {e}")
+ await asyncio.sleep(SCAN_INTERVAL_S)
+
+
+# ---------------------------------------------------------------------------
+# HTTP endpoints (port 9893)
+# ---------------------------------------------------------------------------
+@app.get("/health")
+async def health():
+ return store.health_summary()
+
+
+@app.get("/tree")
+async def get_tree():
+ return store.get_tree()
+
+
+@app.post("/event")
+async def ingest_event(request: Request):
+ """Generic ingest - any agent or process can POST a contract-schema event."""
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "invalid json"}, status_code=400)
+
+ agent_id = body.get("agent_id", "")
+ if not agent_id:
+ return JSONResponse({"error": "agent_id required"}, status_code=400)
+
+ session = body.get("session", "external")
+ agent = store.upsert(agent_id, session)
+
+ # Update state from posted event
+ if body.get("label"):
+ agent.label = body["label"]
+ if body.get("model"):
+ agent.model = body["model"]
+ if body.get("status"):
+ agent.status = body["status"]
+ if body.get("tokens"):
+ agent.tokens = max(agent.tokens, int(body["tokens"]))
+ host = body.get("host") or agent.host
+ agent.host = host
+ if body.get("tool"):
+ agent.current_tool = body["tool"]
+ if body.get("type") in ("tool_call", "child_spawned"):
+ agent.task_count += 1
+
+ # Parent nesting: explicit parent_id wins (true recursive nesting). If a
+ # parent is named but not yet seen, stub it so the edge isn't dropped.
+ # Otherwise, group non-ct215 agents under a synthetic host node, and
+ # ct215 agents under the root.
+ parent_id = body.get("parent_id")
+ if parent_id:
+ if parent_id not in store.agents:
+ stub = store.upsert(parent_id, session)
+ stub.host = host
+ stub.parent_id = ensure_host_node(store, host)
+ agent.parent_id = parent_id
+ elif not agent.parent_id:
+ agent.parent_id = ensure_host_node(store, host)
+
+ ev_ts = body.get("ts", datetime.now(timezone.utc).isoformat())
+ agent.last_activity = ev_ts
+ # Liveness for collector-sourced nodes: recency of the event itself. Use the
+ # event timestamp when it parses to something sane, else treat as 'now'.
+ ev_epoch = _parse_ts(ev_ts)
+ if ev_epoch <= 0:
+ ev_epoch = time.time()
+ agent.last_activity_ts = max(agent.last_activity_ts, ev_epoch)
+
+ # Fan-out the event to all WS clients
+ await ws_manager.broadcast(body)
+ return {"ok": True, "agent_id": agent_id}
+
+
+# ---------------------------------------------------------------------------
+# WebSocket endpoint (port 9892 via separate uvicorn or same with path)
+# Since we run one uvicorn on 9893, WS is at ws://host:9893/stream
+# Port 9892 is aliased by the systemd ExecStart binding
+# ---------------------------------------------------------------------------
+@app.websocket("/stream")
+async def websocket_stream(ws: WebSocket):
+ await ws_manager.connect(ws)
+ try:
+ # Send current tree snapshot on connect
+ snapshot = store.get_tree()
+ await ws.send_text(json.dumps({"type": "snapshot", **snapshot}))
+ # Keep alive - just drain any messages from client
+ while True:
+ try:
+ await asyncio.wait_for(ws.receive_text(), timeout=30)
+ except asyncio.TimeoutError:
+ # send ping
+ await ws.send_text(json.dumps({"type": "ping", "ts": datetime.now(timezone.utc).isoformat()}))
+ except WebSocketDisconnect:
+ pass
+ except Exception:
+ pass
+ finally:
+ ws_manager.disconnect(ws)
+
+
+if __name__ == "__main__":
+ import uvicorn
+ # HTTP+WS on 9893; WS clients connect to ws://host:9893/stream
+ uvicorn.run("deck:app", host="0.0.0.0", port=9893, log_level="info")
deck/requirements.txt +5 -0
@@ -0,0 +1,5 @@
+fastapi==0.115.0
+uvicorn[standard]==0.30.6
+websockets==12.0
+pydantic==2.9.2
+watchdog==4.0.2
deploy_cortex.sh +61 -0
@@ -0,0 +1,61 @@
+#!/bin/bash
+set -e
+PVE="ssh -o ConnectTimeout=10 root@REDACTED-IP"
+SHARE=/shared/projects/darwin/cortex
+
+echo "=== 1. patch adapters to env-driven ==="
+cd "$SHARE/adapters"
+for pair in "rag:9876" "cbm:9877" "serena:9878" "memgraph:9879"; do
+ name=${pair%%:*}; port=${pair##*:}; NAME=$(echo "$name" | tr a-z A-Z)
+ grep -q "^import os" "$name.py" || sed -i '/^from __future__ import annotations$/a import os' "$name.py"
+ sed -i "s|^${NAME}_URL = \"http://127.0.0.1:${port}/sse\"|${NAME}_URL = os.environ.get(\"${NAME}_URL\", \"http://127.0.0.1:${port}/sse\")|" "$name.py"
+done
+echo "--- patched URL lines ---"; grep -nE "_URL = os.environ" *.py
+
+echo "=== 2. re-package + ship to CT252 (keep existing .venv) ==="
+tar czf /tmp/darwin-cortex.tgz --exclude='.venv' --exclude='__pycache__' -C /shared/projects/darwin cortex
+cat /tmp/darwin-cortex.tgz | $PVE "cat > /tmp/darwin-cortex.tgz"
+$PVE "pct push 252 /tmp/darwin-cortex.tgz /tmp/darwin-cortex.tgz && pct exec 252 -- tar xzf /tmp/darwin-cortex.tgz -C /opt/darwin"
+
+echo "=== 3. write /opt/darwin/.env ==="
+cat <<'ENVEOF' | $PVE "pct exec 252 -- tee /opt/darwin/.env >/dev/null"
+RAG_BRIDGE_URL=http://REDACTED-IP:9875
+RAG_URL=http://REDACTED-IP:9876/sse
+CBM_URL=http://REDACTED-IP:9877/sse
+SERENA_URL=http://REDACTED-IP:9878/sse
+MEMGRAPH_URL=http://REDACTED-IP:9879/sse
+MEMOS_BASE_URL=http://REDACTED-IP:5230
+MEMOS_ACCESS_TOKEN=memos_pat_0i7mGB8payehc01j6hW2MJDEOuZ2MVDo
+DARWIN_QWEN_URL=http://REDACTED-IP:11434/api/chat
+DARWIN_QWEN_MODEL=qwen2.5:7b
+DARWIN_QWEN_TIMEOUT=20
+DARWIN_QWEN_KEEP_ALIVE=30m
+ENVEOF
+
+echo "=== 4. write systemd unit ==="
+cat <<'SVCEOF' | $PVE "pct exec 252 -- tee /etc/systemd/system/darwin-cortex.service >/dev/null"
+[Unit]
+Description=Darwin Cortex Gateway
+After=network.target
+[Service]
+Type=simple
+WorkingDirectory=/opt/darwin/cortex
+EnvironmentFile=/opt/darwin/.env
+ExecStart=/opt/darwin/cortex/.venv/bin/uvicorn main:app --host 0.0.0.0 --port 9890
+Restart=always
+RestartSec=3
+[Install]
+WantedBy=multi-user.target
+SVCEOF
+
+echo "=== 5. import check + enable + start ==="
+$PVE "pct exec 252 -- bash -c 'cd /opt/darwin/cortex && . .venv/bin/activate && python -c \"import main\" && echo IMPORT_OK'"
+$PVE "pct exec 252 -- bash -c 'systemctl daemon-reload && systemctl enable --now darwin-cortex && sleep 5 && systemctl is-active darwin-cortex'"
+
+echo "=== 6. verify /health (CT252 localhost) ==="
+$PVE "pct exec 252 -- bash -c 'curl -s -m 8 http://127.0.0.1:9890/health || echo HEALTH_FAIL'"; echo
+echo "=== 7. verify /health over LAN from CT215 ==="
+curl -s -m 8 http://REDACTED-IP:9890/health || echo LAN_HEALTH_FAIL; echo
+echo "=== 8. live /ask smoke (Hermes status - the origin bug) ==="
+curl -s -m 20 -X POST http://REDACTED-IP:9890/ask -H 'Content-Type: application/json' -d '{"query":"what is the current status of Hermes","mode":"both","k":5}' | head -c 2000; echo
+echo "=== DARWIN CORTEX DEPLOY COMPLETE ==="
docs/DARWIN_BLUEPRINT.md +281 -0
@@ -0,0 +1,281 @@
+# DARWIN - Blueprint & North Star
+
+**A living, evolving second brain for Zion's entire digital life.**
+Status: Phase 0 (design + scaffold). Author: Claude (Opus), overseer build. Last updated: 2026-06-24.
+
+> Darwin is not a fifth memory tool. It is the **face and the unifier** of the four that already
+> exist (homelab-rag, cbm-homelab, Serena, memory-graph), plus a capture inbox, plus a recursive
+> agent command deck - wrapped in an Awwwards-tier interface that is *alive*.
+
+---
+
+## 0. Why the past attempts failed (the thing we are correcting)
+
+Every prior attempt built a **retrieval engine** and called it a brain. A retrieval engine is a
+hippocampus. A *second brain* is a hippocampus **+ a front door + a capture habit + a freshness
+discipline.** Zion has four excellent engines and never built the other three. Darwin builds the
+connective tissue and the storefront. **This is an integration + front-door project, not a new
+research project.** ~80% already exists; we wire it together and give it a face.
+
+The named failure mode: **a static brain rots.** Stale facts look identical to true ones. (Proof:
+this very session, the agent confidently reported Hermes drawdowns and DropHawk status as live -
+both are dead/parked - because it read its own un-decayed memory.) Darwin's core thesis fixes this:
+**knowledge under natural selection.**
+
+---
+
+## 1. The thesis - Darwin = evolution
+
+The brain **evolves**, like its namesake:
+
+- **Reinforcement** - facts/sources you reach for rank higher and link tighter.
+- **Decay** - what you never touch sinks; not deleted, just demoted, so the surface stays signal.
+- **Selection pressure** - every query, correction, and click is a fitness signal. Darwin gets
+ fitter the more it's used, and it adapts ranking *to Zion specifically* over time.
+- **Self-healing** - like a business, files/state change constantly; Darwin detects the delta and
+ updates only what changed. It **never flushes the whole index.**
+
+---
+
+## 2. Architecture - three layers + one inbox
+
+```
+ ┌─────────────────────────────────────────────┐
+ │ THE FACE (Awwwards organism UI) │
+ │ ask box · answer+receipts · agent nervous │
+ │ system · inbox · health-as-anatomy │
+ └───────────────┬─────────────────────────────┘
+ │ one verb: ask()
+ ┌───────────────▼─────────────────────────────┐
+ │ THE CORTEX (unified gateway + synthesizer) │
+ │ parallel fan-out · source-of-truth ranking │
+ │ recency/confidence · clickable receipts │
+ │ ALSO the single MCP every agent calls │
+ └──┬──────┬───────┬────────┬──────────┬────────┘
+ │ │ │ │ │
+ homelab-rag cbm Serena memory-graph INBOX
+ (what/where) (code) (symbols) (decisions) (captures)
+ │
+ ┌──────────────┴───────────────────────────────────┐
+ │ SELF-HEALING INGESTION (change-only rolling) │
+ │ reuse homelab-rag collectors; extend to White, │
+ │ 3060, browser/shell/clipboard history │
+ └───────────────────────────────────────────────────┘
+
+ ┌─────────────────────────────────────────────┐
+ │ THE DECK (recursive agent command center) │
+ │ one event bus · live force-directed tree │
+ │ you→agents→their agents→theirs · attach SSH │
+ └─────────────────────────────────────────────┘
+```
+
+### 2.1 The Cortex (the keystone - never built before)
+A single service exposing one verb: **`ask`**. It:
+1. Fans out **in parallel** to all engines, each answering what it's best at:
+ - **homelab-rag** → content / what / where / why (765k chunks, FTS + Qdrant hybrid)
+ - **cbm-homelab** → code architecture, call graph, blast radius
+ - **Serena** → live symbol truth
+ - **memory-graph** → decisions, ownership, handoffs
+ - **Inbox** → Zion's raw captures
+2. **Reconciles** conflicts via the source-of-truth hierarchy (§4).
+3. **Synthesizes** one answer - Opus for hard questions, local qwen3 (3060) for cheap ones.
+4. Attaches **clickable receipts** - every claim shows `{source, path, last_seen, confidence}`.
+ No claim ships without provenance. This is how we kill "didn't trust the answers."
+5. Is **also exposed as the unified MCP** (`darwin.ask`) so Zion and the agents share one brain.
+ Four tools collapse into one verb.
+
+### 2.2 The Inbox (capture / accounts-receivable)
+Frictionless capture, zero filing decisions. Reuse what exists: **Memos / notes.lab** + the
+**cand_memos voice pipeline** land into the Cortex within seconds. Capture-to-searchable is the
+A/R department; today it exists but isn't wired to the brain.
+
+### 2.3 The Deck (recursive agent command center)
+One **event bus**. Every agent (Claude, GLM, Codex, schedulers, loops) emits structured events
+(`spawned`, `tool_call`, `child_spawned`, `returned`, `error`) to a single stream. The Deck renders
+a **live force-directed tree**: you → your agents → their agents → theirs, arbitrarily deep, each
+node showing model, status, token spend, current tool. Click a node → attach to its terminal (the
+Odysseus Cockpit already does in-SPA SSH panes; we extend that, not rebuild).
+
+### 2.4 The Face - see §6.
+
+---
+
+## 3. Self-healing ingestion (the silo that heals itself)
+
+**Reuse, don't rebuild.** homelab-rag already does change-only rolling ingest across many endpoints
+(detects changed files, updates only those, never flushes; state DB on local ext4 at
+`/var/tmp/homelab-rag/state.sqlite`; dedup contract: p-drive is the only source for
+`/shared/projects`). Darwin **extends collectors**, it does not replace the engine.
+
+**Contract (every source obeys):**
+- content-hash per item → only changed items re-embed
+- deletions **tombstoned**, never orphaned
+- every item carries `{source, path, mtime, last_seen, confidence}`
+- one source per endpoint; no overlapping roots (preserve the dedup contract)
+
+**New territory to grab** (gaps confirmed by reuse-map recon - see §9):
+- **White** desktop: all files + **browser history** (Chrome/Edge `History` SQLite),
+ **PowerShell PSReadLine** history, app data, chat logs
+- **3060** box: files + endpoint data
+- **clipboard history**, **shell history** across CTs
+- Going-forward continuous capture agent (clipboard + browser + shell + app activity)
+
+**Honest boundary (precision matters):** we can index everything ever *saved*. Keystrokes typed and
+never persisted anywhere are unrecoverable; nothing can retroactively recover those. A forward
+capture agent tightens the net from here on. No keylogger time-machine promises.
+
+**PRIVACY GATE:** deploying a continuous capture agent onto Zion's *personal* White desktop
+(browser history, keystrokes) is privacy-sensitive and outward-affecting. It is **staged + flagged
+for Zion's explicit approval**, never silently deployed - even under autonomy. Homelab/Proxmox
+indexing proceeds freely.
+
+---
+
+## 4. Source of truth (Zion's ruling, recency-gated)
+
+When sources disagree, authority order:
+
+```
+live infra state > recent files > agent memory > older notes
+```
+
+**Guardrail:** agent memory rides above older notes *only while fresh*. Every fact carries
+`last_seen` + confidence; once stale it **decays below** the notes it would normally outrank. This
+is the exact valve that turns "trust agent memory" from the liability that lied about Hermes back
+into an asset. Staleness is structural, not vibes.
+
+---
+
+## 5. Proactive model (comes to you, doesn't nag)
+
+- **Reactive by default** - answers when asked.
+- **24-hour dead-man's check-in** - if Zion hasn't talked to Darwin in 24h, it reaches out with a
+ tight brief (what changed, what's stale, what's stuck waiting on him). Surfaced as *"Darwin
+ stirred -"*.
+- **No push-push-push.** Beyond the daily heartbeat, it stays silent unless asked.
+
+---
+
+## 6. The Face - design (locked direction: surprise accepted)
+
+**Darwin IS a living organism, and the organism is made of Zion's data.** Not a Greek bust (the refs
+use dead statues because they have nothing alive to show; Zion does). Every cell = a real data
+source (Proxmox, White, 3060, browser history, each agent).
+
+- **On load:** it doesn't fade in, it **evolves in** - a primordial speck self-assembles
+ (cell → cluster → organism) in ~1.2s on easeOutExpo.
+- **On scroll:** it specializes; the agent section lights up the **nervous system = the recursive
+ agent tree**, synapses firing as agents actually work.
+- **On focus (ask-field):** the organism collapses inward and folds into the cursor - "the brain
+ becomes a question." Ask → synapses fire down the exact paths queried (RAG→cbm→Serena→graph) →
+ the answer assembles from glowing trails, each with a clickable receipt.
+- **Health = anatomy (the instrument, not decoration):** fresh/healthy data → vibrant,
+ bioluminescent, moving. A stale source → that limb **greys and withers** on screen. Hermes would
+ have been a visibly dead limb you'd *see* before anyone spoke it. Self-healing = visible
+ regeneration.
+- **Darwinian touch:** cursor lingering → organism grows toward you (attention = selection pressure,
+ visible reinforcement). Neglected past the 24h window → it hibernates and dims.
+
+**Aesthetic:** near-black canvas; brutalist monolithic type holding the corners (Monolith's
+discipline); one **bioluminescent accent** (deep-sea glow, Odysseus teal). Tension between cold
+brutalist geometry and a warm living form *is* the signature. One surface, both modes (Zion's
+choice): left = living agent tree, center = ask → answer + receipts, right = raw hits + inbox.
+
+### 6.1 Motion system (from confirmed reference teardown)
+Premium feel = **one signature move + a disciplined easing dialect**, not WebGL everywhere (only 1
+of 5 refs used real 3D, and pre-baked at 30fps). Stack: **React + GSAP (ScrollTrigger/Flip/SplitText)
++ Lenis + React-Three-Fiber**.
+
+Two global easing tokens drive everything:
+```
+--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1) // entrances, reveals (GSAP "expo.out")
+--ease-inout-expo: cubic-bezier(0.87, 0, 0.13, 1) // symmetric flips (GSAP "power4.inOut")
+```
+Buildable primitives (durations/triggers in `_ops/motion-system.md`): `lenis-scroll-base`,
+`fade-rise-stagger` (opacity0→1,y24→0, 0.6s, stagger .09), `mask-reveal-up` (SplitText lines,
+yPercent110→0, 0.9s), `clip-wipe-reveal`, the **signature** `evolving-organism` (R3F, scroll-scrubbed,
+pre-baked path available so the 3060 doesn't sweat), `magnetic-hover`, `invert-cursor`,
+`gradient-aurora` (bake static per thirdweb #703), `logo-marquee`. 80% of polish = Lenis base +
+fade-rise + mask-reveal + the one signature + magnetic hover, all in the easeOutExpo dialect with
+low-amplitude restraint.
+
+---
+
+## 7. Hosting & cutover (HARD GATES)
+
+1. **Build the new Darwin CT fresh** - clean, isolated, fully working. Nothing touches Odysseus.
+2. **Checkpoint + archive the ENTIRE Odysseus (CT245)** - full PBS backup, verified restorable,
+ **non-destructive**. Safe anytime.
+3. **STOP.** Do **NOT** tear down CT245 or reallocate its resources until Zion explicitly says go -
+ that teardown kills the session he types in (cockpit window + phone Claude code-tab remote both
+ ride the cockpit transport). Hard gate. His word only.
+4. **Cutover choreography:** Darwin's CT must be a fully working front door - *including
+ remote-control transport* - **before** Odysseus dies. New door opens → Zion walks through →
+ *then* the go-word → reallocation. Zero blackout.
+
+---
+
+## 8. Phase plan + delegation matrix (overseer; right-size the model)
+
+| Phase | Deliverable | Driver | Risk |
+|---|---|---|---|
+| 0 | Blueprint + scaffold + recon | Opus (me) | none |
+| 1 | New Darwin CT provisioned | me + SSH/PVE | low (reversible) |
+| 1b | Odysseus checkpointed (non-destructive) | me | none |
+| 2 | Cortex gateway + unified `ask` MCP | Opus design, **Sonnet/Codex** impl | med |
+| 3 | Inbox wire-in (Memos + voice) | **Sonnet** | low |
+| 4 | Deck event bus + recursive tree | **Sonnet/GLM** | med |
+| 5 | The Face (organism UI) | **GLM-5.2 + Codex**, Opus vision-gate | med |
+| 6 | Ingestion extension (White/3060/history) | **Haiku** collectors, Opus privacy-gate | med (privacy) |
+| 7 | Cutover (gated on Zion's go) | me | HIGH - gated |
+
+Model routing: Haiku = grunt indexing/collectors; Sonnet = services; Opus = architecture + hard
+synthesis + quality gate; GLM-5.2 + Codex = front-end masterpiece + parallel tracks. Every delegated
+agent carries the RAG-first + targeted-discovery rule.
+
+---
+
+## 9. Recon findings (folded in from grounding agents)
+
+_(populated from infra-grounding + reuse-map agents - see §9.1, §9.2)_
+
+### 9.1 Infra / capacity
+_pending agent a7f3bd8 - VMID, node, capacity, Odysseus footprint, PBS checkpoint command._
+
+### 9.2 Reuse map (CONFIRMED)
+
+**Four memory stores Darwin's gateway fans out to (all CT215, all SSE):**
+- `homelab-rag` - content RAG, FTS + Qdrant (`homelab_chunks`, qdrant `127.0.0.1:6333`), ~765k chunks - **SSE :9876**
+- `cbm-homelab` - code graph (~1.9M nodes, 112 projects), store `/var/tmp/cbm-homelab/graph` - **SSE :9877**
+- `Serena` - live symbol nav (one project at a time) - **SSE :9878**
+- `memory-graph` - decisions/handoffs, SQLite `/var/tmp/memory-graph/graph.sqlite` - **SSE :9879**
+
+**Change-only engine to REUSE verbatim (Darwin's self-heal primitive):**
+- State manifest `/var/tmp/homelab-rag/state.sqlite` (local ext4 - NEVER on CIFS, returns `database is locked`)
+- Sources registry `/shared/projects/homelab-rag/config/sources.yaml`; driver `ops/ingest-all.sh`; hourly `homelab-rag-ingest.timer`
+- Delta logic: walk roots → compare mtime+size (sha256+size for cross-dup) → emit `new/changed/unchanged/tombstoned`; deletions tombstoned not orphaned; key = `source_id + normalized_path`
+- Live proof: p-drive saw 31,127 files, processed only 28 changed + 8 tombstoned. **No flush.**
+- Collectors: local walker (p-drive); Linux SSH python manifest walker; **Windows PowerShell-over-SSH `-EncodedCommand`** (base64 UTF-16LE)
+- Dedup contract: p-drive is the ONLY source for `/shared/projects`; one source per endpoint
+
+**Coverage - REUSE vs BUILD:**
+| Target | Status |
+|---|---|
+| Fleet config/code/docs across ~16 CTs | ✅ COVERED (reuse) |
+| 3060 box (`desktop-f8o1v2s`, .19) `C:\homelab` + Documents, 38k files | ✅ COVERED |
+| White PC (`desktop-qi823gr`, 100.76.195.52) files | ❌ GAP - source **disabled**, only scoped `C:\homelab` (absent). Re-enable with narrow root `C:\Users\zion\{Documents,Desktop}` |
+| Browser history (anywhere) | ❌ GAP - ship the History SQLite DBs directly (Chrome/Edge `…\User Data\Default\History`; Firefox `places.sqlite`; copy locked DB to temp, ship rows by `last_visit_time` delta) |
+| PowerShell history | ❌ GAP - `…\PSReadLine\ConsoleHost_history.txt`, append-only byte-offset delta |
+| Linux shell history per-CT | ❌ GAP - `~/.bash_history`; set `PROMPT_COMMAND='history -a'` to flush live, ship line delta |
+| Clipboard history | ❌ GAP - only gap needing a **resident daemon** (Windows watcher / greenclip→JSONL), then append-only ship |
+
+All gaps reuse the same manifest + delta + transports. No new index engine.
+
+**Housekeeping noticed:** `ct243-media` stale since 06-19; `desktop-qi823gr` (White) disabled; `ct236`/`ct120` sources disabled (CTs stopped/offline).
+
+---
+
+## 10. Naming & identity
+- **Darwin** - the brain that evolves. Health = anatomy. Decay = honesty.
+- Lives at (proposed) `REDACTED-HOST` behind Authentik SSO.
+- Pantheon-adjacent to Hermes/Perseus/Odysseus but its own metaphor: not a god, a *living thing*.
face/README.md +91 -0
@@ -0,0 +1,91 @@
+# Darwin - The Face
+
+The Awwwards-tier living-organism front door for Darwin, Zion's second brain. A single surface
+that is *alive*: an R3F node-cluster creature made of real data sources, a three-pane query surface
+(agent nervous system · ask + receipts · raw hits + inbox), and health-as-anatomy that withers
+stale sources on screen.
+
+Near-black `#050607` canvas, one bioluminescent teal accent `#16e0c8`, brutalist monolithic type
+(Space Grotesk display / Inter UI / JetBrains Mono), easeOutExpo `cubic-bezier(0.16,1,0.3,1)`
+motion dialect.
+
+## Stack
+
+Next.js 14 (App Router) · TypeScript · Tailwind · GSAP (ScrollTrigger) · @studio-freight/lenis ·
+@react-three/fiber + drei · d3-force.
+
+## Develop
+
+The `/shared/projects` share is **noexec CIFS** - you cannot `npm install` or run there. Develop in
+local ext4:
+
+```bash
+cd /var/tmp/darwin-dev/face
+npm install # one-time
+npm run dev # http://localhost:3000
+```
+
+`/shared/projects/darwin/face/` is a **mirror** (source tree only, no `node_modules`/`.next`). To
+refresh it after editing in dev:
+
+```bash
+rsync -a --delete --exclude=node_modules --exclude=.next --exclude=.git \
+ /var/tmp/darwin-dev/face/ /shared/projects/darwin/face/
+```
+
+## Build
+
+```bash
+cd /var/tmp/darwin-dev/face
+npm run build # validated green - 0 type errors, 0 lint errors
+```
+
+## What is mocked vs real
+
+The Cortex gateway is **not deployed yet**. The app runs fully standalone against typed mocks in
+`src/lib/mock-gateway.ts`, shaped EXACTLY per `cortex/CONTRACTS.md` §2:
+
+| Surface | Source |
+|---|---|
+| `POST /ask` | mock (Hermes-status answer · 4 receipts · ranked hits · engine statuses · conflicts) |
+| `GET /health` | mock (10 sources; `browser-history` is `down` @ `staleness_h 34.9` to demo the wither) |
+| Agent nervous-system tree | mock event data (7 nodes, some active) |
+| **Real Cortex** | activated automatically when `NEXT_PUBLIC_CORTEX_URL` is set |
+
+When `NEXT_PUBLIC_CORTEX_URL` is set, the gateway client hits the real endpoint and falls back to
+the mock on any error, so the surface never goes blank.
+
+## Env vars
+
+Copy `.env.local.example` → `.env.local`:
+
+```
+NEXT_PUBLIC_CORTEX_URL=http://REDACTED-IP:9890 # leave unset to use mocks
+```
+
+## Deploy to CT 252 (Darwin CT)
+
+1. Add `output: 'standalone'` to `next.config.mjs`.
+2. `npm run build` in `/var/tmp/darwin-dev/face`.
+3. Copy `.next/standalone` + `.next/static` + `public` to CT 252.
+4. Set `NEXT_PUBLIC_CORTEX_URL=http://REDACTED-IP:9890` in the CT 252 environment (the Cortex
+ HTTP API per CONTRACTS.md runs on `:9890`).
+5. `node server.js` (standalone entrypoint) behind the CT's reverse proxy.
+6. Front it at `REDACTED-HOST` behind Authentik SSO per blueprint §10.
+
+> Cutover is **gated on Zion's explicit go** (blueprint §7). Do not reallocate Odysseus/CT245
+> resources until the new door is fully working including remote-control transport.
+
+## Signature behaviors
+
+- **Evolve-in:** on load all nodes spawn from origin → cluster positions over ~1.2s (expo-out).
+- **Health-as-anatomy:** fresh sources glow bioluminescent teal; staleness greys then withers
+ (shrinks + dims) the node; `down` sources render dead-grey. Driven live by `/health`.
+- **Cursor attraction:** nodes within range drift toward the cursor (attention = selection
+ pressure).
+- **Focus → collapse:** focusing the ask field folds the organism inward toward center and swells
+ the core glow - "the brain becomes a question."
+- **Receipts:** every answer claim carries a clickable receipt chip (`[source] path · confidence%`)
+ that expands to the snippet + last-seen timestamp. No claim without provenance.
+
+All motion respects `prefers-reduced-motion` (GSAP/Lenis skipped, transitions no-op).
face/WORKSPACE_BUILD.md +58 -0
@@ -0,0 +1,58 @@
+# Darwin - Team-Up Workspace Build Spec (binding)
+
+Build the multi-agent terminal workspace into Darwin's Face. This is the Cockpit replacement that
+lets Zion retire Odysseus. **OBEY `/shared/projects/darwin/_ops/DESIGN_LOCK.md`** - brutalist
+near-black/teal, NOT generic terminal-app or dashboard chrome. Run the design gate before "done."
+
+## Zion's 5 requirements (all must be met)
+1. **Integrated** - agent terminals live INSIDE Darwin (a route/tab in the Face), not a separate app.
+2. **Scrollable** - each pane scrolls its full chat/terminal history (xterm scrollback, pane NOT
+ height-broken - the pane body must give xterm a real height; fix the Cockpit's constrained-cell bug).
+3. **Resizable** - draggable gutters between panes (use `react-resizable-panels` or equivalent). The
+ Cockpit never had this; add it.
+4. **Stable overlay** - NO absolute-inset takeover / sibling `display:none` hack. Use a real Next.js
+ route + normal flow + React state so it never glitches the layout.
+5. **Collapsible sidebar** + clean **1/2/3/4-up split**, each pane = an agent Zion picks
+ (Claude/Codex/OpenCode-GLM/shell), all resizable. Sidebar collapses to give a single full-bleed pane.
+
+## Mobile (HARD requirement)
+- Phone (≤820px): the split collapses to **swipeable single-pane tabs** (one agent at a time, tab/sw­ipe
+ to switch) - the Cockpit's `matchMedia` pattern but cleaner. Sidebar becomes a bottom-sheet/drawer.
+- Thumb-reachable launch controls, legible mono type, no horizontal scroll, tap targets ≥44px.
+- On-brand per DESIGN_LOCK (brutalist editorial, near-black, teal) - NOT a generic mobile terminal app.
+
+## Backend (LIVE - wire to it, don't rebuild)
+`darwin-term` on **CT215:9894** (token-gated; token in `/opt/darwin/.env` on CT215 - read it, bake into
+the Face server-side or proxy so the browser gets it; do NOT hardcode in client source committed to git -
+use a Next.js server route/env). Protocol:
+- **WS** `ws://REDACTED-IP:9894/ws?target=&agent=&mode=&session=&cwd=&token=`
+ - targets: `ct215`(claude), `ct241`(codex), `ct247`(opencode/GLM), + `shell`; modes `persistent|ephemeral`
+ - client→server: **binary** = keystrokes; **text JSON** `{"t":"resize","cols":N,"rows":N}` = resize
+ - server→client: **binary** = PTY output → `term.write()`; **text** = banner; **text JSON**
+ `{"_cockpit":"session","name":...}` = persistent session id (track for attach/close)
+- `GET /targets?token=` → TARGETS/AGENT_CMD/COMMON_DIRS maps → build the launcher dynamically
+- `GET /sessions?token=` → live tmux sessions (Attach/Kill list)
+
+## Front-end stack & approach
+- Next.js (existing Face app) + **`@xterm/xterm` + `@xterm/addon-fit` v5** (npm; the Cockpit used vendored
+ xterm v4 - use modern packages, same API). One xterm instance per pane; `term.onData`→binary WS send;
+ WS binary→`term.write`; on container resize → FitAddon.fit() (GUARD against fitting a hidden/0×0 pane -
+ only fit visible panes, else it sends a bogus tiny PTY resize and garbles TUIs) → send resize control frame.
+- Layout: `react-resizable-panels` for the 1-4 split with drag gutters; React state for pane count/focus
+ (no manual DOM bookkeeping). A real route (e.g. `/workspace` or a top-level tab on the main surface).
+- Launcher sidebar (collapsible): Source/Agent/Mode/cwd selects + Launch + a Local-models (3060) panel +
+ live-sessions list (Attach/Kill, poll /sessions). Sidebar collapse → single full pane.
+- xterm theme from Darwin tokens (near-black bg, teal cursor/accent) - terminal must look ON-BRAND.
+
+## Reuse notes (from recon)
+- Port the Cockpit's agent-launch UX (source→agent→cwd→mode→launch) but rebuild the layout natively.
+- Persistent mode = tmux (survives reload); ephemeral dies with socket. Surface the session id for reattach.
+- Token: the browser needs it for the WS. Cleanest: a Next.js API route on the Face proxies/injects the
+ token (kept server-side) OR serve it via a server component env - never commit the token to client git.
+
+## Verify / deliver
+- Build on local ext4, redeploy Face to CT252 (`/opt/darwin/face`, restart `darwin-face`).
+- Prove: open `/workspace`, launch a `shell` on ct215 → live terminal, type a command, see output, SCROLL
+ back, RESIZE a pane via gutter, collapse the sidebar, switch 1→2→3→4 split. Take desktop + mobile (390px)
+ screenshots to `/shared/projects/darwin/face/_review/` (`workspace-desktop.png`, `workspace-mobile.png`).
+- Run the DESIGN_LOCK gate. If it looks like a generic terminal/dashboard, redo bolder.
face/_review/after-desktop.png +0 -0
Binary file not shown
face/_review/after-mobile.png +0 -0
Binary file not shown
face/_review/before-desktop-tall.png +0 -0
Binary file not shown
face/_review/before-desktop.png +0 -0
Binary file not shown
face/_review/deck-after.svg +3 -0
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="300" viewBox="0 0 2400 300" style="background:#050607">
+<text x="20" y="25" fill="#16e0c8" font-family="monospace" font-size="14" font-weight="bold">DARWIN DECK v1 - 35 active / 96 total / 5061k tokens</text>
+<text x="20" y="45" fill="#3a3a3a" font-family="monospace" font-size="10">Source: CT215 agent transcripts · ws://REDACTED-IP:9893/stream</text><line x1="170" y1="130" x2="50" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="40" x2="170" y2="130" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="170" y1="130" x2="170" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="170" y1="130" x2="290" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="410" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="40" x2="1430" y2="130" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="530" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="650" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="770" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="890" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="1010" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="1130" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="1250" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="1370" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="1490" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="1610" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="1730" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="1850" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="1970" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="2090" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="2210" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="1430" y1="130" x2="2330" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="1430" y1="130" x2="2450" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="2570" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="40" x2="5270" y2="130" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="2690" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="2810" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="2930" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="3050" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="3170" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="3290" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="3410" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="3530" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="3650" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="3770" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="3890" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="4010" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="4130" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="4250" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="4370" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="4490" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="4610" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="4730" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="4850" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="4970" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="5090" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="5210" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="5330" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="5450" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="5570" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="5690" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="5810" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="5930" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="6050" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="6170" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="6290" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="6410" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="6530" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="6650" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="6770" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="6890" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="7010" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="7130" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="7250" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="7370" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="7490" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="7610" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="5270" y1="130" x2="7730" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="7850" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="130" x2="7970" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="8210" y1="130" x2="8090" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="40" x2="8210" y2="130" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="8570" y1="130" x2="8450" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="40" x2="8570" y2="130" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="8570" y1="130" x2="8570" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="8210" y1="130" x2="8210" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="8210" y1="130" x2="8330" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="8570" y1="130" x2="8690" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="9590" y1="130" x2="8810" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="40" x2="9590" y2="130" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="9590" y1="130" x2="8930" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="9050" y2="220" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><line x1="9590" y1="130" x2="9170" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="9290" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="9410" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="9530" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="9650" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="9770" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="9890" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="10010" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="10130" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="10250" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="9590" y1="130" x2="10370" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="10490" y1="130" x2="10490" y2="220" stroke="#2a2a2a" stroke-width="1" opacity="0.15"/><line x1="5270" y1="40" x2="10490" y2="130" stroke="#16e0c8" stroke-width="1" opacity="0.4"/><circle cx="5270" cy="40" r="14" fill="#16e0c8" fill-opacity="1.0"/><text x="5270" y="66" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">DARWIN</text><circle cx="50" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="50" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are researchin</text><text x="50" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">44k</text><circle cx="170" cy="130" r="10" fill="#16e0c8" fill-opacity="0.7"/><text x="170" y="152" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">session:042b6f74</text><circle cx="170" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="170" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are researchin</text><text x="170" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">41k</text><circle cx="290" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="290" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are creating i</text><text x="290" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">34k</text><circle cx="410" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="410" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are a recon ag</text><text x="410" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">58k</text><circle cx="1430" cy="130" r="10" fill="#16e0c8" fill-opacity="0.7"/><text x="1430" y="152" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">session:f0e93741</text><circle cx="530" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="530" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are a motion/w</text><text x="530" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">38k</text><circle cx="650" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="650" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are a senior m</text><text x="650" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">59k</text><circle cx="770" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="770" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Build the Darwin D</text><text x="770" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">69k</text><circle cx="890" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="890" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are a motion/w</text><text x="890" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">35k</text><circle cx="1010" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="1010" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are a motion/w</text><text x="1010" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">41k</text><circle cx="1130" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="1130" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are building T</text><text x="1130" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">76k</text><circle cx="1250" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="1250" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Deploy the Darwin </text><text x="1250" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">33k</text><circle cx="1370" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="1370" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Build + deploy ALL</text><text x="1370" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">172k</text><circle cx="1490" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="1490" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are a motion/w</text><text x="1490" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">34k</text><circle cx="1610" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="1610" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are building T</text><text x="1610" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">107k</text><circle cx="1730" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="1730" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Surgically correct</text><text x="1730" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">53k</text><circle cx="1850" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="1850" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are building t</text><text x="1850" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">62k</text><circle cx="1970" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="1970" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">The Darwin Cortex </text><text x="1970" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">151k</text><circle cx="2090" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="2090" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Get Darwin's Corte</text><text x="2090" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">85k</text><circle cx="2210" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="2210" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are a motion/w</text><text x="2210" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">38k</text><circle cx="2330" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="2330" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Polish the LIVE Da</text><text x="2330" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">107k</text><circle cx="2450" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="2450" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are an infra r</text><text x="2450" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">40k</text><circle cx="2570" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="2570" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="2570" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">45k</text><circle cx="5270" cy="130" r="10" fill="#16e0c8" fill-opacity="0.7"/><text x="5270" y="152" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">session:29030077</text><circle cx="2690" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="2690" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Simplify the contr</text><text x="2690" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">32k</text><circle cx="2810" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="2810" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Build a STATIC, Gi</text><text x="2810" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">70k</text><circle cx="2930" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="2930" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are an adversa</text><text x="2930" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">64k</text><circle cx="3050" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="3050" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">EXPOSURE SCAN (rea</text><text x="3050" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">31k</text><circle cx="3170" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="3170" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are an adversa</text><text x="3170" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">54k</text><circle cx="3290" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="3290" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are doing a RE</text><text x="3290" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">35k</text><circle cx="3410" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="3410" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Two things on Zion</text><text x="3410" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">38k</text><circle cx="3530" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="3530" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are transcribi</text><text x="3530" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">46k</text><circle cx="3650" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="3650" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">EXPOSURE SCAN (rea</text><text x="3650" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">22k</text><circle cx="3770" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="3770" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">EXPOSURE SCAN (rea</text><text x="3770" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">22k</text><circle cx="3890" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="3890" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Apply two fixes to</text><text x="3890" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">76k</text><circle cx="4010" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="4010" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are transcribi</text><text x="4010" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">55k</text><circle cx="4130" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="4130" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="4130" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">38k</text><circle cx="4250" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="4250" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="4250" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">57k</text><circle cx="4370" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="4370" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="4370" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">41k</text><circle cx="4490" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="4490" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are transcribi</text><text x="4490" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">57k</text><circle cx="4610" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="4610" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">EXPOSURE SCAN (rea</text><text x="4610" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">21k</text><circle cx="4730" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="4730" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="4730" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">39k</text><circle cx="4850" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="4850" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You own zionboggan</text><text x="4850" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">111k</text><circle cx="4970" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="4970" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You own the zionbo</text><text x="4970" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">71k</text><circle cx="5090" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="5090" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Improve the contri</text><text x="5090" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">84k</text><circle cx="5210" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="5210" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Two fixes to Zion </text><text x="5210" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">70k</text><circle cx="5330" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="5330" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Produce a PROD-ACC</text><text x="5330" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">112k</text><circle cx="5450" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="5450" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="5450" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">39k</text><circle cx="5570" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="5570" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">EXPOSURE SCAN (rea</text><text x="5570" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">21k</text><circle cx="5690" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="5690" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="5690" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">39k</text><circle cx="5810" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="5810" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Expand Zion Boggan</text><text x="5810" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">83k</text><circle cx="5930" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="5930" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Scrub all em-dashe</text><text x="5930" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">43k</text><circle cx="6050" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="6050" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Fix the "Working H</text><text x="6050" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">43k</text><circle cx="6170" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="6170" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Restyle the static</text><text x="6170" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">83k</text><circle cx="6290" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="6290" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="6290" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">36k</text><circle cx="6410" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="6410" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Fix two regression</text><text x="6410" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">35k</text><circle cx="6530" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="6530" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="6530" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">36k</text><circle cx="6650" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="6650" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are doing a RE</text><text x="6650" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">32k</text><circle cx="6770" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="6770" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="6770" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">34k</text><circle cx="6890" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="6890" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="6890" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">38k</text><circle cx="7010" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="7010" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are an adversa</text><text x="7010" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">58k</text><circle cx="7130" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="7130" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Add a mobile navig</text><text x="7130" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">43k</text><circle cx="7250" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="7250" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">Build the STATIC c</text><text x="7250" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">79k</text><circle cx="7370" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="7370" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are doing a RE</text><text x="7370" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">47k</text><circle cx="7490" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="7490" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are doing a RE</text><text x="7490" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">33k</text><circle cx="7610" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="7610" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">Improve the contri</text><text x="7610" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">68k</text><circle cx="7730" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="7730" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EXPOSURE SCAN (rea</text><text x="7730" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">40k</text><circle cx="7850" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="7850" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are working on</text><text x="7850" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">73k</text><circle cx="7970" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="7970" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are working on</text><text x="7970" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">51k</text><circle cx="8090" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="8090" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are an ops sub</text><text x="8090" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">51k</text><circle cx="8210" cy="130" r="10" fill="#16e0c8" fill-opacity="0.7"/><text x="8210" y="152" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">session:c86bfb1d</text><circle cx="8450" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="8450" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are building t</text><text x="8450" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">77k</text><circle cx="8570" cy="130" r="10" fill="#16e0c8" fill-opacity="0.7"/><text x="8570" y="152" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">session:40e5dae2</text><circle cx="8570" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="8570" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are bringing t</text><text x="8570" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">128k</text><circle cx="8210" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="8210" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">You are an ops sub</text><text x="8210" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">29k</text><circle cx="8330" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="8330" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are an ops sub</text><text x="8330" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">54k</text><circle cx="8690" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="8690" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">You are building t</text><text x="8690" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">167k</text><circle cx="8810" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="8810" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY RULES (</text><text x="8810" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">45k</text><circle cx="9590" cy="130" r="10" fill="#16e0c8" fill-opacity="0.7"/><text x="9590" y="152" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">session:df59f231</text><circle cx="8930" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="8930" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Stay s</text><text x="8930" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">59k</text><circle cx="9050" cy="220" r="7" fill="#16e0c8" fill-opacity="0.7"/><text x="9050" y="239" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">EFFICIENCY: Self-c</text><text x="9050" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">60k</text><circle cx="9170" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="9170" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY RULES: </text><text x="9170" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">63k</text><circle cx="9290" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="9290" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Target</text><text x="9290" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">56k</text><circle cx="9410" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="9410" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Target</text><text x="9410" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">50k</text><circle cx="9530" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="9530" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Target</text><text x="9530" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">61k</text><circle cx="9650" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="9650" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Read O</text><text x="9650" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">72k</text><circle cx="9770" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="9770" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Self-c</text><text x="9770" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">45k</text><circle cx="9890" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="9890" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY RULES (</text><text x="9890" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">40k</text><circle cx="10010" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="10010" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Self-c</text><text x="10010" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">42k</text><circle cx="10130" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="10130" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY RULES (</text><text x="10130" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">56k</text><circle cx="10250" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="10250" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY RULES (</text><text x="10250" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">36k</text><circle cx="10370" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="10370" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">EFFICIENCY: Target</text><text x="10370" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">43k</text><circle cx="10490" cy="220" r="7" fill="#3a3a3a" fill-opacity="0.7"/><text x="10490" y="239" text-anchor="middle" font-size="9" fill="#4a525e" font-family="monospace">I need to verify a</text><text x="10490" y="249" text-anchor="middle" font-size="8" fill="#16e0c8" font-family="monospace" opacity="0.6">61k</text><circle cx="10490" cy="130" r="10" fill="#16e0c8" fill-opacity="0.7"/><text x="10490" y="152" text-anchor="middle" font-size="9" fill="#e8eaed" font-family="monospace">session:98ee3e42</text></svg>
face/_review/deck-tree-snapshot.json +1 -0
@@ -0,0 +1 @@
+{"ts":"2026-06-24T03:11:53.286715+00:00","nodes":[{"agent_id":"ct215-root","parent_id":null,"label":"DARWIN","model":"system","status":"running","tokens":0,"host":"ct215","session":"system","spawned_at":"2026-06-24T03:06:29.698002+00:00","last_activity":"2026-06-24T03:06:29.698007+00:00","current_tool":null,"task_count":7},{"agent_id":"ad4aa911540c0e4a1","parent_id":"session-042b6f74","label":"You are researching people for a job interview prep deck. Use WebSearch and WebF","model":"sonnet","status":"done","tokens":44508,"host":"ct215","session":"042b6f74-10e0-4547-a96b-9ad8ec1db52d","spawned_at":"2026-06-22T14:47:08.602Z","last_activity":"2026-06-22T14:48:47.602Z","current_tool":null,"task_count":15},{"agent_id":"session-042b6f74","parent_id":"ct215-root","label":"session:042b6f74","model":"orchestrator","status":"running","tokens":0,"host":"ct215","session":"042b6f74-10e0-4547-a96b-9ad8ec1db52d","spawned_at":"2026-06-24T03:06:29.737307+00:00","last_activity":"2026-06-24T03:06:29.737312+00:00","current_tool":null,"task_count":0},{"agent_id":"a94cf0ea2fb538c1a","parent_id":"session-042b6f74","label":"You are researching a company for a job interview prep deck. The candidate (Zion","model":"sonnet","status":"done","tokens":41118,"host":"ct215","session":"042b6f74-10e0-4547-a96b-9ad8ec1db52d","spawned_at":"2026-06-22T14:47:00.134Z","last_activity":"2026-06-22T14:48:49.456Z","current_tool":null,"task_count":9},{"agent_id":"a65d30167752cac5a","parent_id":"session-042b6f74","label":"You are creating interview-prep study content for a \"GRC Cybersecurity Analyst\"","model":"sonnet","status":"running","tokens":34466,"host":"ct215","session":"042b6f74-10e0-4547-a96b-9ad8ec1db52d","spawned_at":"2026-06-22T14:47:24.653Z","last_activity":"2026-06-22T14:50:10.075Z","current_tool":"WebSearch","task_count":6},{"agent_id":"a5cc7b8b10b5133df","parent_id":"session-f0e93741","label":"You are a recon agent mapping what indexing ALREADY EXISTS in Zion's homelab so","model":"sonnet","status":"done","tokens":58147,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T00:08:28.651Z","last_activity":"2026-06-24T00:09:49.871Z","current_tool":null,"task_count":6},{"agent_id":"session-f0e93741","parent_id":"ct215-root","label":"session:f0e93741","model":"orchestrator","status":"running","tokens":0,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T03:06:29.743495+00:00","last_activity":"2026-06-24T03:06:29.743500+00:00","current_tool":null,"task_count":0},{"agent_id":"a9c2686e33dc978d3","parent_id":"session-f0e93741","label":"You are a motion/web-design researcher. Research the animation and transition la","model":"sonnet","status":"running","tokens":38065,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-23T23:50:46.586Z","last_activity":"2026-06-23T23:52:38.246Z","current_tool":"WebFetch","task_count":15},{"agent_id":"aef0a033fb4215002","parent_id":"session-f0e93741","label":"You are a senior motion/web-design researcher. I'm designing an Awwwards-tier in","model":"sonnet","status":"done","tokens":59700,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-23T23:50:14.731Z","last_activity":"2026-06-23T23:54:48.351Z","current_tool":null,"task_count":9},{"agent_id":"aacf98b814ad73b6d","parent_id":"session-f0e93741","label":"Build the Darwin DECK v1 - the live recursive agent tree (\"see every agent, and","model":"sonnet","status":"running","tokens":71316,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T03:02:05.353Z","last_activity":"2026-06-24T03:11:50.215Z","current_tool":"Bash","task_count":69},{"agent_id":"a7befe88096efedd6","parent_id":"session-f0e93741","label":"You are a motion/web-design researcher. Research the animation and transition la","model":"sonnet","status":"done","tokens":35510,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-23T23:50:33.407Z","last_activity":"2026-06-23T23:51:54.301Z","current_tool":null,"task_count":10},{"agent_id":"aa2f7116a851435ca","parent_id":"session-f0e93741","label":"You are a motion/web-design researcher. Research the animation and transition la","model":"sonnet","status":"running","tokens":41266,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-23T23:50:39.053Z","last_activity":"2026-06-23T23:53:19.711Z","current_tool":"Bash","task_count":13},{"agent_id":"acc239cc229db7719","parent_id":"session-f0e93741","label":"You are building THE FACE of Darwin - Zion's second brain - as an Awwwards-tier","model":"sonnet","status":"done","tokens":76659,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T00:28:46.111Z","last_activity":"2026-06-24T00:41:08.149Z","current_tool":null,"task_count":37},{"agent_id":"a83f604543b18c545","parent_id":"session-f0e93741","label":"Deploy the Darwin Face (Next.js app) to CT252 as a running service, wired to the","model":"sonnet","status":"done","tokens":33881,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T01:17:40.925Z","last_activity":"2026-06-24T01:23:32.381Z","current_tool":null,"task_count":20},{"agent_id":"ab34452911adf4802","parent_id":"session-f0e93741","label":"Build + deploy ALL of Darwin's net-new ingestion collectors. Zion's directive: \"","model":"sonnet","status":"done","tokens":172786,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T01:40:37.140Z","last_activity":"2026-06-24T02:22:26.931Z","current_tool":"Bash","task_count":129},{"agent_id":"a5901e70ad02845b9","parent_id":"session-f0e93741","label":"You are a motion/web-design researcher. Research the animation and transition la","model":"sonnet","status":"done","tokens":34794,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-23T23:50:51.734Z","last_activity":"2026-06-23T23:52:07.094Z","current_tool":null,"task_count":7},{"agent_id":"ad144bcf934ab3d1f","parent_id":"session-f0e93741","label":"You are building THE FACE of Darwin - Zion's second brain - and it must be an Aw","model":"sonnet","status":"running","tokens":107463,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T00:26:54.145Z","last_activity":"2026-06-24T00:45:20.806Z","current_tool":"Bash","task_count":43},{"agent_id":"a94f2be351f2b14e1","parent_id":"session-f0e93741","label":"Surgically correct the stale `/shared/projects/CLAUDE.md` (Zion's canonical ops","model":"sonnet","status":"running","tokens":53011,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T01:38:55.865Z","last_activity":"2026-06-24T01:41:30.761Z","current_tool":"Edit","task_count":18},{"agent_id":"a5160ed9e57a06d81","parent_id":"session-f0e93741","label":"You are building the Darwin Cortex gateway - the keystone of Zion's unified seco","model":"sonnet","status":"done","tokens":62461,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T00:17:15.821Z","last_activity":"2026-06-24T00:25:42.547Z","current_tool":null,"task_count":42},{"agent_id":"ad2afbd8651d9a7b7","parent_id":"session-f0e93741","label":"The Darwin Cortex gateway is deployed and running but BROKEN in one specific way","model":"sonnet","status":"done","tokens":151817,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T00:41:13.814Z","last_activity":"2026-06-24T01:16:29.655Z","current_tool":null,"task_count":141},{"agent_id":"a06569ad0d91845a1","parent_id":"session-f0e93741","label":"Get Darwin's Cortex INBOX adapter returning real Memos notes in `/ask`. Zion app","model":"sonnet","status":"running","tokens":85848,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T01:43:32.734Z","last_activity":"2026-06-24T01:55:12.232Z","current_tool":"Bash","task_count":104},{"agent_id":"aec2dc7bf40a29f74","parent_id":"session-f0e93741","label":"You are a motion/web-design researcher. Research the animation and transition la","model":"sonnet","status":"done","tokens":38744,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-23T23:50:27.751Z","last_activity":"2026-06-23T23:52:01.785Z","current_tool":null,"task_count":12},{"agent_id":"a7b933e5b6cd58ef1","parent_id":"session-f0e93741","label":"Polish the LIVE Darwin Face UI to Awwwards calibre. The user looked at it and sa","model":"sonnet","status":"running","tokens":107864,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T01:36:54.267Z","last_activity":"2026-06-24T01:53:15.901Z","current_tool":"Bash","task_count":95},{"agent_id":"a7f3bd8292e9e9f09","parent_id":"session-f0e93741","label":"You are an infra recon agent on Zion's Proxmox homelab (you're on CT215, 192.168","model":"sonnet","status":"done","tokens":40798,"host":"ct215","session":"f0e93741-bb70-4e04-85d3-17a5b6e70666","spawned_at":"2026-06-24T00:08:17.583Z","last_activity":"2026-06-24T00:10:10.372Z","current_tool":null,"task_count":9},{"agent_id":"af36b488cb371bd70","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of a repo before it's republished publicly on Zion Bog","model":"sonnet","status":"done","tokens":45246,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:27:38.709Z","last_activity":"2026-06-22T16:34:16.243Z","current_tool":null,"task_count":13},{"agent_id":"session-29030077","parent_id":"ct215-root","label":"session:29030077","model":"orchestrator","status":"running","tokens":0,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-24T03:06:29.900077+00:00","last_activity":"2026-06-24T03:06:29.900082+00:00","current_tool":null,"task_count":0},{"agent_id":"af258dbc37dcb3a7f","parent_id":"session-29030077","label":"Simplify the contribution-graph section of Zion Boggan's static code browser so","model":"sonnet","status":"running","tokens":32297,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T21:37:25.952Z","last_activity":"2026-06-22T21:38:00.083Z","current_tool":"Read","task_count":7},{"agent_id":"a4b7a3694ad84931a","parent_id":"session-29030077","label":"Build a STATIC, GitHub-like code-browser site generator (Node.js) and render a 2","model":"sonnet","status":"running","tokens":70308,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T11:16:43.810Z","last_activity":"2026-06-22T11:27:09.044Z","current_tool":"Edit","task_count":30},{"agent_id":"adf976e43b2a1fd64","parent_id":"session-29030077","label":"You are an adversarial fact-checker verifying transcribed workout data against t","model":"sonnet","status":"running","tokens":64644,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T09:48:27.772Z","last_activity":"2026-06-22T09:50:34.081Z","current_tool":"Read","task_count":4},{"agent_id":"a6c7e2934508866ad","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) before republishing publicly on Zion Boggan's portfoli","model":"sonnet","status":"running","tokens":31581,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:35:02.984Z","last_activity":"2026-06-22T16:40:58.169Z","current_tool":"Bash","task_count":8},{"agent_id":"aad4df94098781476","parent_id":"session-29030077","label":"You are an adversarial fact-checker verifying transcribed workout data against t","model":"sonnet","status":"done","tokens":54465,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T09:47:47.835Z","last_activity":"2026-06-22T09:49:32.384Z","current_tool":null,"task_count":3},{"agent_id":"a3be2a6cbe034718b","parent_id":"session-29030077","label":"You are doing a READ-ONLY security exposure scan of a git repository for a publi","model":"sonnet","status":"done","tokens":35161,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:21:39.804Z","last_activity":"2026-06-22T20:22:47.504Z","current_tool":null,"task_count":8},{"agent_id":"acce8ff91f8ee45cf","parent_id":"session-29030077","label":"Two things on Zion Boggan's static code browser: fix the TreeTrace brand capital","model":"sonnet","status":"done","tokens":38774,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T21:55:15.149Z","last_activity":"2026-06-22T22:00:32.494Z","current_tool":null,"task_count":25},{"agent_id":"abd35edde70fde30d","parent_id":"session-29030077","label":"You are transcribing real paying-customer workout data for the SevenStar \"Summer","model":"sonnet","status":"running","tokens":46846,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T09:44:11.258Z","last_activity":"2026-06-22T09:46:37.313Z","current_tool":"Read","task_count":3},{"agent_id":"a3044a41e2e2bdc79","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of mullvad-ios-killswitch before republishing on Zion","model":"sonnet","status":"running","tokens":22219,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:12:58.932Z","last_activity":"2026-06-22T18:17:59.796Z","current_tool":"Bash","task_count":3},{"agent_id":"af72247bb4daa7883","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of pitch-tracker-cv before republishing on Zion Boggan","model":"sonnet","status":"running","tokens":22122,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:12:50.931Z","last_activity":"2026-06-22T18:18:01.692Z","current_tool":"Bash","task_count":3},{"agent_id":"a1a69c5f518891a7d","parent_id":"session-29030077","label":"Apply two fixes to Zion Boggan's static code browser, then re-render + redeploy","model":"sonnet","status":"running","tokens":76127,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:28:04.654Z","last_activity":"2026-06-22T20:53:02.750Z","current_tool":"Bash","task_count":42},{"agent_id":"ad4d5ffa542353725","parent_id":"session-29030077","label":"You are transcribing real paying-customer workout data for the SevenStar \"Summer","model":"sonnet","status":"done","tokens":55814,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T09:43:47.449Z","last_activity":"2026-06-22T09:45:32.250Z","current_tool":null,"task_count":3},{"agent_id":"ad18086ad8e2b92ab","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of a repo before it's republished publicly on Zion Bog","model":"sonnet","status":"done","tokens":38805,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:28:04.493Z","last_activity":"2026-06-22T16:34:12.210Z","current_tool":null,"task_count":10},{"agent_id":"a1da33fbdf568fdbc","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of TreeTrace before its code+history is published on Z","model":"sonnet","status":"done","tokens":57365,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:07:03.058Z","last_activity":"2026-06-22T18:13:18.186Z","current_tool":null,"task_count":14},{"agent_id":"a1f123209cb21d341","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) before republishing publicly on Zion Boggan's portfoli","model":"sonnet","status":"done","tokens":41328,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:41:47.783Z","last_activity":"2026-06-22T16:45:15.773Z","current_tool":null,"task_count":7},{"agent_id":"a7a841c060d5ca0d5","parent_id":"session-29030077","label":"You are transcribing real paying-customer workout data for the SevenStar \"Summer","model":"sonnet","status":"done","tokens":57533,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T09:44:00.148Z","last_activity":"2026-06-22T09:45:52.040Z","current_tool":null,"task_count":3},{"agent_id":"ac351a1291d5b82a6","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of deckbound before republishing on Zion Boggan's port","model":"sonnet","status":"running","tokens":21047,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:13:06.588Z","last_activity":"2026-06-22T18:18:05.343Z","current_tool":"Bash","task_count":2},{"agent_id":"aca303bf85fd2bf35","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) before republishing publicly on Zion Boggan's portfoli","model":"sonnet","status":"done","tokens":39611,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:34:56.090Z","last_activity":"2026-06-22T16:41:25.985Z","current_tool":null,"task_count":12},{"agent_id":"a7d9b33b3bf8a2e39","parent_id":"session-29030077","label":"You own zionboggan.com site additions, built from the GENERATOR source, deployed","model":"sonnet","status":"done","tokens":111471,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:20:31.293Z","last_activity":"2026-06-22T20:29:52.226Z","current_tool":null,"task_count":46},{"agent_id":"acf6b2c190e4dce19","parent_id":"session-29030077","label":"You own the zionboggan.com site changes. Work entirely from the GENERATOR source","model":"sonnet","status":"running","tokens":71926,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:11:07.532Z","last_activity":"2026-06-22T18:13:13.844Z","current_tool":"Edit","task_count":20},{"agent_id":"a9a5d5905e1ed2aaa","parent_id":"session-29030077","label":"Improve the contribution graph + add cross-links in Zion Boggan's static code br","model":"sonnet","status":"done","tokens":84278,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T21:14:21.416Z","last_activity":"2026-06-22T21:27:51.602Z","current_tool":null,"task_count":45},{"agent_id":"ac8473e99dd74c955","parent_id":"session-29030077","label":"Two fixes to Zion Boggan's static code browser, then re-render + redeploy. WORKD","model":"sonnet","status":"done","tokens":70660,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T21:38:28.599Z","last_activity":"2026-06-22T21:46:29.346Z","current_tool":null,"task_count":55},{"agent_id":"a80e909c1a2fe32aa","parent_id":"session-29030077","label":"Produce a PROD-ACCURATE preview of zionboggan.com with several additions, from t","model":"sonnet","status":"done","tokens":112984,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:53:57.448Z","last_activity":"2026-06-22T21:13:36.435Z","current_tool":null,"task_count":67},{"agent_id":"a33cd6aa8f079aab6","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of a repo before it's republished publicly on Zion Bog","model":"sonnet","status":"done","tokens":39717,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:27:47.648Z","last_activity":"2026-06-22T16:34:10.372Z","current_tool":null,"task_count":9},{"agent_id":"ae0b2d970f0bccfd0","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of gpu-cpu-mutex before republishing on Zion Boggan's","model":"sonnet","status":"running","tokens":21865,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:12:48.382Z","last_activity":"2026-06-22T18:17:59.484Z","current_tool":"Bash","task_count":3},{"agent_id":"a2b2f8b7a51417ec2","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of a repo before it's republished publicly on Zion Bog","model":"sonnet","status":"done","tokens":39861,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:27:56.121Z","last_activity":"2026-06-22T16:34:01.224Z","current_tool":null,"task_count":10},{"agent_id":"abd806b56eed6d49c","parent_id":"session-29030077","label":"Expand Zion Boggan's static code browser from 10 to 17 repos, exposure-safe, and","model":"sonnet","status":"running","tokens":83086,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:20:15.257Z","last_activity":"2026-06-22T20:27:03.424Z","current_tool":"Bash","task_count":31},{"agent_id":"ae165d1a0a496c47b","parent_id":"session-29030077","label":"Scrub all em-dashes and en-dashes from Zion Boggan's site content, rebuild, and","model":"sonnet","status":"done","tokens":43041,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T21:28:03.581Z","last_activity":"2026-06-22T21:32:09.920Z","current_tool":null,"task_count":41},{"agent_id":"acda1e4cb98dee50d","parent_id":"session-29030077","label":"Fix the \"Working History\" contribution graph in Zion Boggan's static code browse","model":"sonnet","status":"done","tokens":43226,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T23:32:15.385Z","last_activity":"2026-06-22T23:36:35.754Z","current_tool":null,"task_count":24},{"agent_id":"ac99cd1ecc2f5085a","parent_id":"session-29030077","label":"Restyle the static code-browser generator to MATCH Zion Boggan's zionboggan.com","model":"sonnet","status":"done","tokens":83714,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:06:50.617Z","last_activity":"2026-06-22T18:12:05.572Z","current_tool":null,"task_count":25},{"agent_id":"adfb024ef65138782","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of claude-dispatch before republishing on Zion Boggan'","model":"sonnet","status":"done","tokens":36879,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:07:19.878Z","last_activity":"2026-06-22T18:12:18.068Z","current_tool":null,"task_count":8},{"agent_id":"a047dd01c75a83cac","parent_id":"session-29030077","label":"Fix two regressions in the \"Working History\" contribution graph of Zion Boggan's","model":"sonnet","status":"running","tokens":35566,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T23:52:41.044Z","last_activity":"2026-06-22T23:56:03.770Z","current_tool":"Bash","task_count":17},{"agent_id":"a64676cb5e91d247f","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) of Oversight Protocol before its code+history is publi","model":"sonnet","status":"done","tokens":36646,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T18:07:11.956Z","last_activity":"2026-06-22T18:12:51.930Z","current_tool":null,"task_count":8},{"agent_id":"afc1d8acc59e873aa","parent_id":"session-29030077","label":"You are doing a READ-ONLY security exposure scan of a git repository for a publi","model":"sonnet","status":"done","tokens":32642,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:21:17.069Z","last_activity":"2026-06-22T20:22:25.514Z","current_tool":null,"task_count":9},{"agent_id":"a5f84616290e52797","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) before republishing publicly on Zion Boggan's portfoli","model":"sonnet","status":"done","tokens":34113,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:34:48.376Z","last_activity":"2026-06-22T16:41:35.184Z","current_tool":null,"task_count":12},{"agent_id":"a50c17ac2503dfbc8","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) before republishing publicly on Zion Boggan's portfoli","model":"sonnet","status":"done","tokens":38831,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:35:10.615Z","last_activity":"2026-06-22T16:41:12.219Z","current_tool":null,"task_count":11},{"agent_id":"a96ee26aca9407034","parent_id":"session-29030077","label":"You are an adversarial fact-checker verifying transcribed workout data against t","model":"sonnet","status":"done","tokens":58127,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T09:49:04.747Z","last_activity":"2026-06-22T09:50:56.914Z","current_tool":null,"task_count":3},{"agent_id":"af51cfb1e4b8e0ce9","parent_id":"session-29030077","label":"Add a mobile navigation menu to Zion Boggan's site, built from the generator sou","model":"sonnet","status":"done","tokens":43466,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T23:32:29.906Z","last_activity":"2026-06-22T23:34:46.473Z","current_tool":null,"task_count":19},{"agent_id":"a6eccb2176fe9460c","parent_id":"session-29030077","label":"Build the STATIC code-browser output (dist/) for Zion Boggan's portfolio, for EX","model":"sonnet","status":"done","tokens":79707,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T17:22:45.549Z","last_activity":"2026-06-22T17:30:29.075Z","current_tool":null,"task_count":42},{"agent_id":"a4b06020cb0b681b1","parent_id":"session-29030077","label":"You are doing a READ-ONLY security exposure scan of a git repository for a publi","model":"sonnet","status":"running","tokens":47886,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:21:50.610Z","last_activity":"2026-06-22T20:23:22.420Z","current_tool":"Bash","task_count":15},{"agent_id":"acdee7f2a35ce5b56","parent_id":"session-29030077","label":"You are doing a READ-ONLY security exposure scan of a git repository for a publi","model":"sonnet","status":"done","tokens":33504,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:21:26.481Z","last_activity":"2026-06-22T20:22:23.318Z","current_tool":null,"task_count":7},{"agent_id":"adbd27e08bb810dbb","parent_id":"session-29030077","label":"Improve the contribution graph + add cross-links in Zion Boggan's static code br","model":"sonnet","status":"running","tokens":68043,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T20:55:22.496Z","last_activity":"2026-06-22T21:06:31.610Z","current_tool":"Bash","task_count":47},{"agent_id":"ad300424796acfda6","parent_id":"session-29030077","label":"EXPOSURE SCAN (read-only) before republishing publicly on Zion Boggan's portfoli","model":"sonnet","status":"done","tokens":40272,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-22T16:41:56.779Z","last_activity":"2026-06-22T16:46:04.475Z","current_tool":null,"task_count":12},{"agent_id":"a90d8095e8a5a769f","parent_id":"session-29030077","label":"You are working on the SevenStar Collective Summer Reset Challenge feature in th","model":"sonnet","status":"done","tokens":73770,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-21T21:52:44.178Z","last_activity":"2026-06-21T21:59:20.085Z","current_tool":null,"task_count":36},{"agent_id":"a6f92c9f608e2e747","parent_id":"session-29030077","label":"You are working on the SevenStar Collective website (7starcollective.com). Repo","model":"sonnet","status":"done","tokens":51378,"host":"ct215","session":"29030077-bdc5-4e09-8cf7-b355e23f5e5a","spawned_at":"2026-06-21T21:52:22.483Z","last_activity":"2026-06-21T21:54:21.849Z","current_tool":null,"task_count":17},{"agent_id":"a09dac6fb78ed3d3d","parent_id":"session-c86bfb1d","label":"You are an ops subagent in Zion's homelab (you are on CT215, REDACTED-IP). You","model":"sonnet","status":"done","tokens":51963,"host":"ct215","session":"c86bfb1d-7341-4e98-80a8-204106d9fcb1","spawned_at":"2026-06-22T02:12:21.803Z","last_activity":"2026-06-22T02:23:40.814Z","current_tool":null,"task_count":51},{"agent_id":"session-c86bfb1d","parent_id":"ct215-root","label":"session:c86bfb1d","model":"orchestrator","status":"running","tokens":0,"host":"ct215","session":"c86bfb1d-7341-4e98-80a8-204106d9fcb1","spawned_at":"2026-06-24T03:06:30.129237+00:00","last_activity":"2026-06-24T03:06:30.129242+00:00","current_tool":null,"task_count":0},{"agent_id":"a76aa6ddbd30c3e22","parent_id":"session-40e5dae2","label":"You are building the **infrastructure / deployment glue** for DropHawk Phase 1 (","model":"sonnet","status":"done","tokens":77955,"host":"ct215","session":"40e5dae2-3748-4dd8-a093-073dd3f52d0f","spawned_at":"2026-06-22T03:50:13.857Z","last_activity":"2026-06-22T03:56:01.371Z","current_tool":null,"task_count":42},{"agent_id":"session-40e5dae2","parent_id":"ct215-root","label":"session:40e5dae2","model":"orchestrator","status":"running","tokens":0,"host":"ct215","session":"40e5dae2-3748-4dd8-a093-073dd3f52d0f","spawned_at":"2026-06-24T03:06:30.138025+00:00","last_activity":"2026-06-24T03:06:30.138030+00:00","current_tool":null,"task_count":0},{"agent_id":"a20939fbea86d92ff","parent_id":"session-40e5dae2","label":"You are bringing the **DropHawk** Docker stack LIVE on a fresh container. The co","model":"sonnet","status":"running","tokens":128689,"host":"ct215","session":"40e5dae2-3748-4dd8-a093-073dd3f52d0f","spawned_at":"2026-06-22T04:40:41.953Z","last_activity":"2026-06-22T06:41:16.508Z","current_tool":"Bash","task_count":85},{"agent_id":"a130e4921b79205ee","parent_id":"session-c86bfb1d","label":"You are an ops subagent on CT215 in Zion's homelab. A VNC viewing setup is 95% d","model":"sonnet","status":"done","tokens":29759,"host":"ct215","session":"c86bfb1d-7341-4e98-80a8-204106d9fcb1","spawned_at":"2026-06-22T02:36:20.921Z","last_activity":"2026-06-22T02:40:15.279Z","current_tool":null,"task_count":22},{"agent_id":"a9f289bd345ea5930","parent_id":"session-c86bfb1d","label":"You are an ops subagent in Zion's homelab (you are on CT215, REDACTED-IP; you","model":"sonnet","status":"running","tokens":54056,"host":"ct215","session":"c86bfb1d-7341-4e98-80a8-204106d9fcb1","spawned_at":"2026-06-22T02:12:06.468Z","last_activity":"2026-06-22T02:22:36.179Z","current_tool":"Edit","task_count":42},{"agent_id":"a5f07e02d760fe829","parent_id":"session-40e5dae2","label":"You are building the **Rust backend** for DropHawk Phase 1 (a ticket-drop monito","model":"sonnet","status":"running","tokens":167118,"host":"ct215","session":"40e5dae2-3748-4dd8-a093-073dd3f52d0f","spawned_at":"2026-06-22T03:49:54.517Z","last_activity":"2026-06-22T04:09:05.861Z","current_tool":"Bash","task_count":90},{"agent_id":"a133820c5f61afe24","parent_id":"session-df59f231","label":"EFFICIENCY RULES (follow exactly, do not deviate): Query homelab_context first i","model":"sonnet","status":"done","tokens":45225,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T22:58:38.593Z","last_activity":"2026-06-21T22:59:28.795Z","current_tool":null,"task_count":2},{"agent_id":"session-df59f231","parent_id":"ct215-root","label":"session:df59f231","model":"orchestrator","status":"running","tokens":0,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-24T03:06:30.199292+00:00","last_activity":"2026-06-24T03:06:30.199297+00:00","current_tool":null,"task_count":0},{"agent_id":"a75047e6ccea0fd6a","parent_id":"session-df59f231","label":"EFFICIENCY: Stay strictly within /shared/projects/offsec/. No reads outside it,","model":"sonnet","status":"done","tokens":59523,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:24:05.991Z","last_activity":"2026-06-21T23:28:11.322Z","current_tool":null,"task_count":23},{"agent_id":"ad6b055cb8f408f24","parent_id":"session-df59f231","label":"EFFICIENCY: Self-contained. Inputs are the JSONL files already on disk. No RAG,","model":"sonnet","status":"running","tokens":60620,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:34:35.606Z","last_activity":"2026-06-21T23:40:31.084Z","current_tool":"Bash","task_count":23},{"agent_id":"a6ec741a72adbb91b","parent_id":"session-df59f231","label":"EFFICIENCY RULES: Read ONLY the three named files below. No other reads, no RAG,","model":"sonnet","status":"done","tokens":63341,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:18:48.684Z","last_activity":"2026-06-21T23:20:48.799Z","current_tool":null,"task_count":4},{"agent_id":"aa09aac8238f4dd65","parent_id":"session-df59f231","label":"EFFICIENCY: Targeted. READ the existing script to reuse its working skeleton; do","model":"sonnet","status":"done","tokens":56409,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-22T03:13:17.200Z","last_activity":"2026-06-22T03:16:43.229Z","current_tool":null,"task_count":10},{"agent_id":"a0368dccf76a2de73","parent_id":"session-df59f231","label":"EFFICIENCY: Targeted extraction only. No full-tree walks. Write a script, don't","model":"sonnet","status":"done","tokens":50785,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:24:15.109Z","last_activity":"2026-06-21T23:32:42.110Z","current_tool":null,"task_count":38},{"agent_id":"aa7d013d351f02986","parent_id":"session-df59f231","label":"EFFICIENCY: Targeted. READ the existing /shared/projects/personal_llm/scripts/tr","model":"sonnet","status":"done","tokens":61124,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-22T03:36:07.682Z","last_activity":"2026-06-22T03:39:27.468Z","current_tool":null,"task_count":5},{"agent_id":"aff45be6ba567ee4f","parent_id":"session-df59f231","label":"EFFICIENCY: Read ONLY the four files below. No other reads, no RAG, no tree-walk","model":"sonnet","status":"done","tokens":72628,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-22T11:06:35.445Z","last_activity":"2026-06-22T11:08:44.347Z","current_tool":null,"task_count":5},{"agent_id":"a76edc49510a014b9","parent_id":"session-df59f231","label":"EFFICIENCY: Self-contained codegen. No RAG, no tree-walks. Everything you need i","model":"sonnet","status":"done","tokens":45842,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:32:29.624Z","last_activity":"2026-06-21T23:35:36.121Z","current_tool":null,"task_count":15},{"agent_id":"ad0810c75a4bdfda8","parent_id":"session-df59f231","label":"EFFICIENCY RULES (follow exactly): Use homelab_context FIRST to discover facts (","model":"sonnet","status":"done","tokens":40001,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:05:50.827Z","last_activity":"2026-06-21T23:07:10.525Z","current_tool":null,"task_count":9},{"agent_id":"aee61423bb1ecd9bd","parent_id":"session-df59f231","label":"EFFICIENCY: Self-contained build. No RAG, no tree-walks. Everything needed is be","model":"sonnet","status":"done","tokens":42092,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-22T03:00:23.985Z","last_activity":"2026-06-22T03:04:19.945Z","current_tool":null,"task_count":17},{"agent_id":"a52e483b5a9197913","parent_id":"session-df59f231","label":"EFFICIENCY RULES (follow exactly): Use homelab_context(\"cand_memos voice memo pi","model":"sonnet","status":"done","tokens":56355,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T22:58:51.205Z","last_activity":"2026-06-21T23:02:31.918Z","current_tool":null,"task_count":8},{"agent_id":"ad68c1aa449649481","parent_id":"session-df59f231","label":"EFFICIENCY RULES (follow exactly): No full-tree reads. This is a self-contained","model":"sonnet","status":"done","tokens":36214,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:06:12.051Z","last_activity":"2026-06-21T23:12:48.338Z","current_tool":null,"task_count":8},{"agent_id":"ae9d7db96915aa0a3","parent_id":"session-df59f231","label":"EFFICIENCY: Targeted extraction. No full-tree walks. Script it. Use SSH for remo","model":"sonnet","status":"done","tokens":43256,"host":"ct215","session":"df59f231-404b-4964-a2a8-ab3d84dde1cd","spawned_at":"2026-06-21T23:24:33.627Z","last_activity":"2026-06-21T23:27:51.276Z","current_tool":null,"task_count":16},{"agent_id":"a8a27df265c249721","parent_id":"session-98ee3e42","label":"I need to verify a specific factual claim about Claude Code (the CLI tool). Plea","model":"sonnet","status":"done","tokens":61712,"host":"ct215","session":"98ee3e42-0fb8-4d25-9920-0143d1f21ffb","spawned_at":"2026-06-23T13:57:28.879Z","last_activity":"2026-06-23T13:58:15.358Z","current_tool":null,"task_count":7},{"agent_id":"session-98ee3e42","parent_id":"ct215-root","label":"session:98ee3e42","model":"orchestrator","status":"running","tokens":0,"host":"ct215","session":"98ee3e42-0fb8-4d25-9920-0143d1f21ffb","spawned_at":"2026-06-24T03:06:30.296950+00:00","last_activity":"2026-06-24T03:06:30.296955+00:00","current_tool":null,"task_count":0}],"active_count":35,"total_tokens":5062841}
face/_review/desktop-after2.png +0 -0
Binary file not shown
face/_review/desktop-before.png +0 -0
Binary file not shown
face/_review/desktop-surface.png +0 -0
Binary file not shown
face/_review/mobile-after-surface.png +0 -0
Binary file not shown
face/_review/mobile-after.png +0 -0
Binary file not shown
face/_review/mobile-before.png +0 -0
Binary file not shown
face/_review/tablet-after.png +0 -0
Binary file not shown
face/_review/ux-hits.png +0 -0
Binary file not shown
face/_review/ux-mobile-tree.png +0 -0
Binary file not shown
face/_review/ux-mobile.png +0 -0
Binary file not shown
face/_review/ux-organism-confined.png +0 -0
Binary file not shown
face/_review/ux-tree-zoom.png +0 -0
Binary file not shown
face/_review/workspace-desktop-4up.png +0 -0
Binary file not shown
face/_review/workspace-desktop.png +0 -0
Binary file not shown
face/_review/workspace-mobile-drawer.png +0 -0
Binary file not shown
face/_review/workspace-mobile.png +0 -0
Binary file not shown
face/_review/ws-desktop-check.png +0 -0
Binary file not shown
face/_review/ws-fix-desktop.png +0 -0
Binary file not shown
face/_review/ws-fix-mobile.png +0 -0
Binary file not shown
face/_review/ws-fix-picker.png +0 -0
Binary file not shown
face/_review/ws-fix-reflow.png +0 -0
Binary file not shown
face/_review/ws-font-control.png +0 -0
Binary file not shown
face/_review/ws-font-mobile.png +0 -0
Binary file not shown
face/_review/ws-mobile-fit.png +0 -0
Binary file not shown
face/_review/ws-mobile-mine.png +0 -0
Binary file not shown
face/_review/ws-mobile-scrolled.png +0 -0
Binary file not shown
face/_review/ws-now.png +0 -0
Binary file not shown
face/next-env.d.ts +5 -0
@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
face/next.config.mjs +7 -0
@@ -0,0 +1,7 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: "standalone",
+ transpilePackages: ["three", "@react-three/fiber", "@react-three/drei"],
+};
+
+export default nextConfig;
face/package-lock.json +2827 -0
@@ -0,0 +1,2827 @@
+{
+ "name": "darwin-face",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "darwin-face",
+ "version": "0.1.0",
+ "dependencies": {
+ "@react-spring/three": "^9.7.4",
+ "@react-three/drei": "^9.99.0",
+ "@react-three/fiber": "^8.15.19",
+ "@studio-freight/lenis": "^1.0.42",
+ "@types/d3": "^7.4.3",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
+ "d3-force": "^3.0.0",
+ "gsap": "^3.12.5",
+ "lucide-react": "^0.378.0",
+ "next": "14.2.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-resizable-panels": "^2.1.9",
+ "three": "^0.163.0"
+ },
+ "devDependencies": {
+ "@types/d3-force": "^3.0.10",
+ "@types/node": "^20.12.12",
+ "@types/react": "^18.3.2",
+ "@types/react-dom": "^18.3.0",
+ "@types/three": "^0.163.0",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.3",
+ "typescript": "^5.4.5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@mediapipe/tasks-vision": {
+ "version": "0.10.17",
+ "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
+ "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@monogrid/gainmap-js": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
+ "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
+ "license": "MIT",
+ "dependencies": {
+ "promise-worker-transferable": "^1.0.4"
+ },
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz",
+ "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz",
+ "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz",
+ "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz",
+ "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz",
+ "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz",
+ "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz",
+ "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz",
+ "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
+ "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz",
+ "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@react-spring/animated": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz",
+ "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@react-spring/core": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz",
+ "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-spring/donate"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@react-spring/rafz": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz",
+ "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==",
+ "license": "MIT"
+ },
+ "node_modules/@react-spring/shared": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz",
+ "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/rafz": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@react-spring/three": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz",
+ "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~9.7.5",
+ "@react-spring/core": "~9.7.5",
+ "@react-spring/shared": "~9.7.5",
+ "@react-spring/types": "~9.7.5"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": ">=6.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "three": ">=0.126"
+ }
+ },
+ "node_modules/@react-spring/types": {
+ "version": "9.7.5",
+ "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz",
+ "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
+ "license": "MIT"
+ },
+ "node_modules/@react-three/drei": {
+ "version": "9.122.0",
+ "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz",
+ "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.26.0",
+ "@mediapipe/tasks-vision": "0.10.17",
+ "@monogrid/gainmap-js": "^3.0.6",
+ "@react-spring/three": "~9.7.5",
+ "@use-gesture/react": "^10.3.1",
+ "camera-controls": "^2.9.0",
+ "cross-env": "^7.0.3",
+ "detect-gpu": "^5.0.56",
+ "glsl-noise": "^0.0.0",
+ "hls.js": "^1.5.17",
+ "maath": "^0.10.8",
+ "meshline": "^3.3.1",
+ "react-composer": "^5.0.3",
+ "stats-gl": "^2.2.8",
+ "stats.js": "^0.17.0",
+ "suspend-react": "^0.1.3",
+ "three-mesh-bvh": "^0.7.8",
+ "three-stdlib": "^2.35.6",
+ "troika-three-text": "^0.52.0",
+ "tunnel-rat": "^0.1.2",
+ "utility-types": "^3.11.0",
+ "zustand": "^5.0.1"
+ },
+ "peerDependencies": {
+ "@react-three/fiber": "^8",
+ "react": "^18",
+ "react-dom": "^18",
+ "three": ">=0.137"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/fiber": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz",
+ "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.17.8",
+ "@types/react-reconciler": "^0.26.7",
+ "@types/webxr": "*",
+ "base64-js": "^1.5.1",
+ "buffer": "^6.0.3",
+ "its-fine": "^1.0.6",
+ "react-reconciler": "^0.27.0",
+ "react-use-measure": "^2.1.7",
+ "scheduler": "^0.21.0",
+ "suspend-react": "^0.1.3",
+ "zustand": "^3.7.1"
+ },
+ "peerDependencies": {
+ "expo": ">=43.0",
+ "expo-asset": ">=8.4",
+ "expo-file-system": ">=11.0",
+ "expo-gl": ">=11.0",
+ "react": ">=18 <19",
+ "react-dom": ">=18 <19",
+ "react-native": ">=0.64",
+ "three": ">=0.133"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "expo-asset": {
+ "optional": true
+ },
+ "expo-file-system": {
+ "optional": true
+ },
+ "expo-gl": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-three/fiber/node_modules/zustand": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
+ "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@studio-freight/lenis": {
+ "version": "1.0.42",
+ "resolved": "https://registry.npmjs.org/@studio-freight/lenis/-/lenis-1.0.42.tgz",
+ "integrity": "sha512-HJAGf2DeM+BTvKzHv752z6Z7zy6bA643nZM7W88Ft9tnw2GsJSp6iJ+3cekjyMIWH+cloL2U9X82dKXgdU8kPg==",
+ "deprecated": "The '@studio-freight/lenis' package has been renamed to 'lenis'. Please update your dependencies: npm install lenis and visit the documentation: https://www.npmjs.com/package/lenis",
+ "license": "MIT"
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/draco3d": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
+ "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/offscreencanvas": {
+ "version": "2019.7.3",
+ "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
+ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/react-reconciler": {
+ "version": "0.26.7",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz",
+ "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.163.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.163.0.tgz",
+ "integrity": "sha512-uIdDhsXRpQiBUkflBS/i1l3JX14fW6Ot9csed60nfbZNXHDTRsnV2xnTVwXcgbvTiboAR4IW+t+lTL5f1rqIqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tweenjs/tween.js": "~23.1.1",
+ "@types/stats.js": "*",
+ "@types/webxr": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~0.18.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
+ "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
+ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
+ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
+ "license": "MIT"
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
+ "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.1",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.1.tgz",
+ "integrity": "sha512-jwM2pcTuCWUoN70FEvf5XrXyDbUgRURK4FnU8v0jWZZYU/KkVvN9T33mu1sVLFY9JW3kTWzKheEpn6xYLRc/VA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.4",
+ "caniuse-lite": "^1.0.30001799",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.38",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
+ "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.4",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
+ "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.38",
+ "caniuse-lite": "^1.0.30001799",
+ "electron-to-chromium": "^1.5.376",
+ "node-releases": "^2.0.48",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/camera-controls": {
+ "version": "2.10.1",
+ "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz",
+ "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.126.1"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cross-env": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "bin": {
+ "cross-env": "src/bin/cross-env.js",
+ "cross-env-shell": "src/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=10.14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/detect-gpu": {
+ "version": "5.0.70",
+ "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
+ "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
+ "license": "MIT",
+ "dependencies": {
+ "webgl-constants": "^1.1.1"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/draco3d": {
+ "version": "1.5.7",
+ "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
+ "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.378",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz",
+ "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glsl-noise": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
+ "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
+ "license": "MIT"
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/gsap": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
+ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hls.js": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
+ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/its-fine": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
+ "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": ">=18.0"
+ }
+ },
+ "node_modules/its-fine/node_modules/@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.378.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz",
+ "integrity": "sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/maath": {
+ "version": "0.10.8",
+ "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
+ "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/three": ">=0.134.0",
+ "three": ">=0.134.0"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/meshline": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
+ "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.137"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
+ "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
+ "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==",
+ "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "14.2.3",
+ "@swc/helpers": "0.5.5",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.1"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "14.2.3",
+ "@next/swc-darwin-x64": "14.2.3",
+ "@next/swc-linux-arm64-gnu": "14.2.3",
+ "@next/swc-linux-arm64-musl": "14.2.3",
+ "@next/swc-linux-x64-gnu": "14.2.3",
+ "@next/swc-linux-x64-musl": "14.2.3",
+ "@next/swc-win32-arm64-msvc": "14.2.3",
+ "@next/swc-win32-ia32-msvc": "14.2.3",
+ "@next/swc-win32-x64-msvc": "14.2.3"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.48",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
+ "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
+ "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/potpack": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
+ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
+ "license": "ISC"
+ },
+ "node_modules/promise-worker-transferable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
+ "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "is-promise": "^2.1.0",
+ "lie": "^3.0.2"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-composer": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz",
+ "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.6.0"
+ },
+ "peerDependencies": {
+ "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-dom/node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/react-reconciler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz",
+ "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.21.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
+ "node_modules/react-resizable-panels": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz",
+ "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/react-use-measure": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.13",
+ "react-dom": ">=16.13"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz",
+ "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stats-gl": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
+ "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/three": "*",
+ "three": "^0.170.0"
+ },
+ "peerDependencies": {
+ "@types/three": "*",
+ "three": "*"
+ }
+ },
+ "node_modules/stats-gl/node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
+ "node_modules/stats.js": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
+ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
+ "license": "MIT"
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/suspend-react": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=17.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.163.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.163.0.tgz",
+ "integrity": "sha512-HlMgCb2TF/dTLRtknBnjUTsR8FsDqBY43itYop2+Zg822I+Kd0Ua2vs8CvfBVefXkBdNDrLMoRTGCIIpfCuDew==",
+ "license": "MIT"
+ },
+ "node_modules/three-mesh-bvh": {
+ "version": "0.7.8",
+ "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz",
+ "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==",
+ "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">= 0.151.0"
+ }
+ },
+ "node_modules/three-stdlib": {
+ "version": "2.36.1",
+ "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
+ "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/draco3d": "^1.4.0",
+ "@types/offscreencanvas": "^2019.6.4",
+ "@types/webxr": "^0.5.2",
+ "draco3d": "^1.4.1",
+ "fflate": "^0.6.9",
+ "potpack": "^1.0.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.128.0"
+ }
+ },
+ "node_modules/three-stdlib/node_modules/fflate": {
+ "version": "0.6.10",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
+ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/troika-three-text": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
+ "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.2",
+ "troika-three-utils": "^0.52.4",
+ "troika-worker-utils": "^0.52.0",
+ "webgl-sdf-generator": "1.1.1"
+ },
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-three-utils": {
+ "version": "0.52.4",
+ "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
+ "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "three": ">=0.125.0"
+ }
+ },
+ "node_modules/troika-worker-utils": {
+ "version": "0.52.0",
+ "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
+ "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
+ "license": "MIT"
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/tunnel-rat": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
+ "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "zustand": "^4.3.2"
+ }
+ },
+ "node_modules/tunnel-rat/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/utility-types": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
+ "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/webgl-constants": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
+ "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
+ },
+ "node_modules/webgl-sdf-generator": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
+ "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
+ "license": "MIT"
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.14",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
+ "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
face/package.json +39 -0
@@ -0,0 +1,39 @@
+{
+ "name": "darwin-face",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@react-spring/three": "^9.7.4",
+ "@react-three/drei": "^9.99.0",
+ "@react-three/fiber": "^8.15.19",
+ "@studio-freight/lenis": "^1.0.42",
+ "@types/d3": "^7.4.3",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
+ "d3-force": "^3.0.0",
+ "gsap": "^3.12.5",
+ "lucide-react": "^0.378.0",
+ "next": "14.2.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-resizable-panels": "^2.1.9",
+ "three": "^0.163.0"
+ },
+ "devDependencies": {
+ "@types/d3-force": "^3.0.10",
+ "@types/node": "^20.12.12",
+ "@types/react": "^18.3.2",
+ "@types/react-dom": "^18.3.0",
+ "@types/three": "^0.163.0",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.3",
+ "typescript": "^5.4.5"
+ }
+}
face/postcss.config.js +6 -0
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
face/src/app/api/term/kill/route.ts +25 -0
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(req: Request) {
+ const token = process.env.DARWIN_TERM_TOKEN ?? "";
+ const host = process.env.DARWIN_TERM_HOST ?? "REDACTED-IP:9894";
+ if (!token) return NextResponse.json({ ok: false, error: "no token" }, { status: 503 });
+
+ const { searchParams } = new URL(req.url);
+ const target = searchParams.get("target") ?? "";
+ const session = searchParams.get("session") ?? "";
+ const p = new URLSearchParams({ target, session, token });
+ try {
+ const res = await fetch(`http://${host}/kill?${p.toString()}`, { method: "POST" });
+ const body = await res.text();
+ return new NextResponse(body, {
+ status: res.status,
+ headers: { "content-type": "application/json" },
+ });
+ } catch (e) {
+ return NextResponse.json({ ok: false, error: String(e) }, { status: 502 });
+ }
+}
face/src/app/api/term/route.ts +29 -0
@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+
+// Server-only: the darwin-term token + host are read from the systemd unit env.
+// The token is NEVER bundled into the client. The client fetches this at runtime
+// to build an authed WS URL.
+//
+// systemd (darwin-face.service) must set:
+// Environment=DARWIN_TERM_TOKEN=<copied from CT215 /opt/darwin/.env>
+// Environment=DARWIN_TERM_HOST=REDACTED-IP:9894 (optional; defaults below)
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function GET() {
+ const token = process.env.DARWIN_TERM_TOKEN ?? "";
+ const host = process.env.DARWIN_TERM_HOST ?? "REDACTED-IP:9894";
+
+ if (!token) {
+ return NextResponse.json(
+ { ok: false, error: "DARWIN_TERM_TOKEN not set on the server" },
+ { status: 503 }
+ );
+ }
+
+ return NextResponse.json(
+ { ok: true, token, host },
+ { headers: { "cache-control": "no-store" } }
+ );
+}
face/src/app/api/term/sessions/route.ts +23 -0
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function GET() {
+ const token = process.env.DARWIN_TERM_TOKEN ?? "";
+ const host = process.env.DARWIN_TERM_HOST ?? "REDACTED-IP:9894";
+ if (!token) return NextResponse.json({ sessions: [] });
+ try {
+ const res = await fetch(
+ `http://${host}/sessions?token=${encodeURIComponent(token)}`,
+ { cache: "no-store" }
+ );
+ const body = await res.text();
+ return new NextResponse(body, {
+ status: res.status,
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
+ });
+ } catch (e) {
+ return NextResponse.json({ sessions: [], error: String(e) }, { status: 502 });
+ }
+}
face/src/app/api/term/targets/route.ts +23 -0
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function GET() {
+ const token = process.env.DARWIN_TERM_TOKEN ?? "";
+ const host = process.env.DARWIN_TERM_HOST ?? "REDACTED-IP:9894";
+ if (!token) return NextResponse.json({ error: "no token" }, { status: 503 });
+ try {
+ const res = await fetch(
+ `http://${host}/targets?token=${encodeURIComponent(token)}`,
+ { cache: "no-store" }
+ );
+ const body = await res.text();
+ return new NextResponse(body, {
+ status: res.status,
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
+ });
+ } catch (e) {
+ return NextResponse.json({ error: String(e) }, { status: 502 });
+ }
+}
face/src/app/globals.css +318 -0
@@ -0,0 +1,318 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* ─── Design tokens ───────────────────────────────────────────────────────── */
+:root {
+ --color-canvas: #050607;
+ --color-teal: #16e0c8;
+ --color-teal-dim: #0d9e8e;
+ --color-teal-muted: #0a6b60;
+ --color-surface-0: #050607;
+ --color-surface-1: #0a0c0e;
+ --color-surface-2: #0f1214;
+ --color-surface-3: #141820;
+ --color-text-primary: #e8eaed;
+ --color-text-secondary: #8b929e;
+ --color-text-muted: #4a525e;
+
+ --ease-expo-out: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-expo-inout: cubic-bezier(0.87, 0, 0.13, 1);
+}
+
+/* ─── Reset & base ────────────────────────────────────────────────────────── */
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+html {
+ background: var(--color-canvas);
+ color: var(--color-text-primary);
+ font-family: 'Inter', sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Restore cursor on touch devices */
+@media (pointer: coarse) {
+ html { cursor: auto; }
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 3px;
+ height: 3px;
+}
+::-webkit-scrollbar-track {
+ background: var(--color-surface-1);
+}
+::-webkit-scrollbar-thumb {
+ background: var(--color-surface-3);
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-teal-muted);
+}
+
+/* ─── Invert cursor expanded state ───────────────────────────────────────── */
+.invert-cursor.is-expanded {
+ width: 40px !important;
+ height: 40px !important;
+}
+
+/* ─── Typography ──────────────────────────────────────────────────────────── */
+.font-display {
+ font-family: 'Space Grotesk', sans-serif;
+}
+
+.font-ui {
+ font-family: 'Inter', sans-serif;
+}
+
+.font-mono {
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+}
+
+/* ─── Bioluminescent aurora blob (fixed, no pointer events) ─────────────── */
+.aurora-blob {
+ position: fixed;
+ top: -30vh;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 100vw;
+ height: 80vh;
+ background:
+ radial-gradient(ellipse 60% 50% at 50% 40%, rgba(22, 224, 200, 0.07) 0%, transparent 70%),
+ radial-gradient(ellipse 40% 35% at 30% 60%, rgba(22, 224, 200, 0.03) 0%, transparent 60%);
+ pointer-events: none;
+ z-index: 0;
+ animation: aurora-drift 12s ease-in-out infinite alternate;
+}
+
+@keyframes aurora-drift {
+ 0% { opacity: 0.7; transform: translateX(-50%) scale(1); }
+ 100% { opacity: 1; transform: translateX(-50%) scale(1.06); }
+}
+
+/* ─── Hero section ────────────────────────────────────────────────────────── */
+/* The WebGL organism is confined STRICTLY to this section. `isolation` creates a
+ fresh stacking context and `contain: layout paint` clips any GPU bleed so the
+ canvas can never paint over the Hits / Agents / Health views below it. */
+.hero-section {
+ position: relative;
+ height: 100dvh;
+ min-height: 600px;
+ overflow: hidden;
+ isolation: isolate;
+ contain: layout paint size;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+/* The organism canvas wrapper - absolutely clipped to the hero box. */
+.hero-organism {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ overflow: hidden;
+ pointer-events: none;
+}
+.hero-organism canvas {
+ position: absolute !important;
+ inset: 0;
+ width: 100% !important;
+ height: 100% !important;
+}
+
+/* ─── Surface panes ───────────────────────────────────────────────────────── */
+.surface-pane {
+ border: 1px solid rgba(22, 224, 200, 0.08);
+ background: rgba(10, 12, 14, 0.85);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ height: 100%;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ /* Generous padding - editorial breathing room */
+ padding: 2rem 2.25rem;
+ border-radius: 2px;
+}
+
+/* ─── Answer prose (markdown-lite output) ─────────────────────────────────── */
+.answer-prose p {
+ margin: 0 0 0.85em 0;
+}
+.answer-prose p:last-child {
+ margin-bottom: 0;
+}
+.answer-prose strong {
+ color: #f4f6f8;
+ font-weight: 600;
+}
+.answer-prose em {
+ color: var(--color-text-secondary);
+ font-style: italic;
+}
+.answer-prose .ml-list {
+ margin: 0.4em 0 0.85em 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4em;
+}
+.answer-prose .ml-list li {
+ position: relative;
+ padding-left: 1.1em;
+ line-height: 1.6;
+ font-size: 0.95em;
+}
+.answer-prose .ml-list li::before {
+ content: "›";
+ position: absolute;
+ left: 0;
+ top: 0;
+ color: var(--color-teal);
+ font-family: 'JetBrains Mono', monospace;
+}
+.answer-prose .ml-code {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.85em;
+ background: var(--color-surface-2);
+ color: var(--color-teal);
+ padding: 0.08em 0.36em;
+ border-radius: 2px;
+ word-break: break-word;
+}
+
+/* ─── Magnetic hover (applied via JS) ────────────────────────────────────── */
+.magnetic {
+ display: inline-block;
+}
+
+/* ─── Fade-rise animation ─────────────────────────────────────────────────── */
+.fade-rise {
+ opacity: 0;
+ transform: translateY(24px);
+}
+
+.fade-rise.is-visible {
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.6s var(--ease-expo-out), transform 0.6s var(--ease-expo-out);
+}
+
+/* ─── Mask reveal (clip from bottom) ─────────────────────────────────────── */
+.mask-line {
+ overflow: hidden;
+}
+
+.mask-line-inner {
+ display: block;
+ transform: translateY(110%);
+ transition: transform 0.9s var(--ease-expo-out);
+}
+
+.mask-line.is-revealed .mask-line-inner {
+ transform: translateY(0);
+}
+
+/* ─── Mobile surface pane ─────────────────────────────────────────────────── */
+.mobile-surface-pane {
+ padding: 1.5rem 1.25rem;
+ min-height: calc(100dvh - 200px);
+}
+
+/* On mobile the surface pane height should not be constrained */
+@media (max-width: 1023px) {
+ .surface-pane {
+ height: auto;
+ min-height: 0;
+ }
+}
+
+/* ─── Viewport fix for iOS ────────────────────────────────────────────────── */
+@supports (height: 100dvh) {
+ .hero-section {
+ height: 100dvh;
+ }
+}
+
+/* ─── Touch target safety ─────────────────────────────────────────────────── */
+@media (pointer: coarse) {
+ /* Ensure form inputs are big enough on touch */
+ input[type="text"],
+ input[type="search"],
+ button {
+ min-height: 44px;
+ }
+
+ /* Aurora is GPU-expensive on mobile - reduce opacity */
+ .aurora-blob {
+ opacity: 0.4;
+ animation: none;
+ }
+}
+
+/* ─── Workspace (team-up terminal) ───────────────────────────────────────── */
+/* Ensure a real text cursor in the terminal for selecting/clicking. */
+.fixed .xterm,
+body:has([data-darwin-workspace]) {
+ cursor: auto;
+}
+[data-darwin-workspace] * {
+ cursor: auto;
+}
+
+/* xterm viewport: real scrollback height + on-brand thin scrollbar */
+.xterm .xterm-viewport {
+ scrollbar-width: thin;
+ scrollbar-color: #141820 transparent;
+}
+.xterm .xterm-viewport::-webkit-scrollbar {
+ width: 6px;
+}
+.xterm .xterm-viewport::-webkit-scrollbar-track {
+ background: transparent;
+}
+.xterm .xterm-viewport::-webkit-scrollbar-thumb {
+ background: #141820;
+ border-radius: 3px;
+}
+.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
+ background: #0a6b60;
+}
+.xterm,
+.xterm .xterm-screen {
+ height: 100% !important;
+}
+
+@keyframes sheet-up {
+ from { transform: translateY(100%); }
+ to { transform: translateY(0); }
+}
+
+@keyframes glow-breathe {
+ 0%, 100% { opacity: 0.55; }
+ 50% { opacity: 1; }
+}
+
+/* ─── Reduced motion overrides ────────────────────────────────────────────── */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+ .mask-line-inner {
+ transform: none !important;
+ }
+ .fade-rise {
+ opacity: 1 !important;
+ transform: none !important;
+ }
+ .aurora-blob {
+ animation: none !important;
+ }
+}
face/src/app/layout.tsx +44 -0
@@ -0,0 +1,44 @@
+import type { Metadata } from "next";
+import { Space_Grotesk, Inter, JetBrains_Mono } from "next/font/google";
+import "./globals.css";
+
+const spaceGrotesk = Space_Grotesk({
+ subsets: ["latin"],
+ weight: ["400", "500", "600", "700"],
+ variable: "--font-space-grotesk",
+ display: "swap",
+});
+
+const inter = Inter({
+ subsets: ["latin"],
+ weight: ["400", "500", "600"],
+ variable: "--font-inter",
+ display: "swap",
+});
+
+const jetbrainsMono = JetBrains_Mono({
+ subsets: ["latin"],
+ weight: ["400", "500", "700"],
+ variable: "--font-jetbrains-mono",
+ display: "swap",
+});
+
+export const metadata: Metadata = {
+ title: "Darwin - Second Brain",
+ description: "A living organism made of Zion's real data sources.",
+ robots: "noindex, nofollow",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <html lang="en" className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrainsMono.variable}`}>
+ <body className="bg-canvas text-text-primary font-ui antialiased">
+ {children}
+ </body>
+ </html>
+ );
+}
face/src/app/page.tsx +598 -0
@@ -0,0 +1,598 @@
+"use client";
+
+import { useEffect, useRef, useState, useCallback } from "react";
+import dynamic from "next/dynamic";
+import { useHealth } from "@/hooks/useHealth";
+import { useAsk } from "@/hooks/useAsk";
+import AgentTree from "@/components/surface/AgentTree";
+import HitsInbox from "@/components/surface/HitsInbox";
+import HealthPanel from "@/components/health/HealthPanel";
+import AskBox from "@/components/surface/AskBox";
+import OrganismBoundary from "@/components/organism/OrganismBoundary";
+import type { AskResponse } from "@/lib/types";
+
+// R3F must be client-side only (WebGL)
+const DarwinOrganism = dynamic(
+ () => import("@/components/organism/DarwinOrganism"),
+ { ssr: false, loading: () => null }
+);
+
+// ─── Hero wordmark ─────────────────────────────────────────────────────────
+
+function Wordmark() {
+ const ref = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (prefersReduced) {
+ el.style.opacity = "1";
+ return;
+ }
+ el.style.opacity = "0";
+ el.style.transform = "translateY(28px)";
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ el.style.transition = "opacity 1.1s cubic-bezier(0.16,1,0.3,1), transform 1.1s cubic-bezier(0.16,1,0.3,1)";
+ el.style.opacity = "1";
+ el.style.transform = "translateY(0)";
+ });
+ });
+ }, []);
+
+ return (
+ <div ref={ref} className="text-center pointer-events-none select-none" style={{ opacity: 0 }}>
+ <h1 className="font-display font-bold text-display-xl text-text-primary uppercase tracking-tight">
+ Darwin
+ </h1>
+ <div className="flex items-center justify-center gap-3 mt-3">
+ <div className="h-px w-16 bg-teal-bio opacity-40" aria-hidden="true" />
+ <span className="font-mono text-label-md text-teal-bio uppercase tracking-widest">
+ Second Brain
+ </span>
+ <div className="h-px w-16 bg-teal-bio opacity-40" aria-hidden="true" />
+ </div>
+ <p className="font-mono text-label-sm text-text-muted mt-3 tracking-widest uppercase opacity-60">
+ Knowledge under natural selection
+ </p>
+ </div>
+ );
+}
+
+// ─── Nav ───────────────────────────────────────────────────────────────────
+
+interface NavProps {
+ liveOk: boolean | null;
+}
+
+function Nav({ liveOk }: NavProps) {
+ const indicatorClass =
+ liveOk === null
+ ? "bg-text-muted animate-pulse-slow"
+ : liveOk
+ ? "bg-teal-bio animate-pulse-slow"
+ : "bg-red-500";
+ const liveLabel = liveOk === null ? "CONNECTING" : liveOk ? "LIVE" : "DEGRADED";
+
+ return (
+ <nav
+ className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-4 sm:px-6 py-4"
+ role="navigation"
+ aria-label="Main navigation"
+ >
+ <span className="font-mono text-label-md text-teal-bio uppercase tracking-widest" aria-label="Darwin">
+ DRW
+ </span>
+ <div className="flex items-center gap-4 sm:gap-6">
+ <a href="/workspace" className="font-mono text-label-md text-teal-bio hover:text-text-primary transition-colors duration-200 uppercase tracking-widest border border-teal-bio/30 px-2.5 py-1" data-cursor-expand>Workspace</a>
+ <a
+ href="#surface"
+ className="font-mono text-label-md text-text-muted hover:text-teal-bio transition-colors duration-200 uppercase tracking-widest hidden sm:inline"
+ data-cursor-expand
+ >
+ Query
+ </a>
+ <a
+ href="#health"
+ className="font-mono text-label-md text-text-muted hover:text-teal-bio transition-colors duration-200 uppercase tracking-widest hidden sm:inline"
+ data-cursor-expand
+ >
+ Health
+ </a>
+ <div className="flex items-center gap-1.5" aria-label={`Status: ${liveLabel}`}>
+ <div className={`w-1.5 h-1.5 rounded-full ${indicatorClass}`} aria-hidden="true" />
+ <span className={`font-mono text-label-sm ${liveOk === false ? "text-red-400" : "text-teal-bio"}`}>
+ {liveLabel}
+ </span>
+ </div>
+ </div>
+ </nav>
+ );
+}
+
+// ─── Scroll hint ───────────────────────────────────────────────────────────
+
+function ScrollHint() {
+ const [visible, setVisible] = useState(true);
+
+ useEffect(() => {
+ function onScroll() {
+ if (window.scrollY > 80) setVisible(false);
+ }
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ return (
+ <div
+ className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 transition-opacity duration-500 pointer-events-none"
+ style={{ opacity: visible ? 1 : 0 }}
+ aria-hidden="true"
+ >
+ <span className="font-mono text-label-sm text-text-muted uppercase tracking-widest">
+ Scroll to query
+ </span>
+ <div className="w-px h-8 bg-teal-bio opacity-30 animate-bounce" />
+ </div>
+ );
+}
+
+// ─── Mobile tab bar ─────────────────────────────────────────────────────────
+
+type MobileTab = "ask" | "hits" | "agents" | "health";
+
+const MOBILE_TABS: { id: MobileTab; label: string; short: string }[] = [
+ { id: "ask", label: "Ask", short: "ASK" },
+ { id: "hits", label: "Hits", short: "HITS" },
+ { id: "agents", label: "Agents", short: "AGENTS" },
+ { id: "health", label: "Health", short: "HEALTH" },
+];
+
+interface MobileTabBarProps {
+ active: MobileTab;
+ onChange: (tab: MobileTab) => void;
+ hitCount: number;
+ healthOk: boolean | null;
+}
+
+function MobileTabBar({ active, onChange, hitCount, healthOk }: MobileTabBarProps) {
+ return (
+ <div
+ className="fixed bottom-0 left-0 right-0 z-50 lg:hidden"
+ style={{
+ background: "rgba(5,6,7,0.97)",
+ borderTop: "1px solid rgba(22,224,200,0.12)",
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
+ }}
+ role="tablist"
+ aria-label="Surface navigation"
+ >
+ <div className="grid grid-cols-5">
+ {MOBILE_TABS.map((tab) => {
+ const isActive = active === tab.id;
+ return (
+ <button
+ key={tab.id}
+ role="tab"
+ aria-selected={isActive}
+ aria-label={tab.label}
+ onClick={() => onChange(tab.id)}
+ className={[
+ "flex flex-col items-center justify-center gap-1 py-3 px-1 min-h-[56px]",
+ "font-mono text-label-sm uppercase tracking-widest transition-colors duration-200",
+ isActive ? "text-teal-bio" : "text-text-muted",
+ ].join(" ")}
+ style={{ touchAction: "manipulation" }}
+ >
+ {/* Minimal geometric icon */}
+ <TabIcon id={tab.id} active={isActive} />
+ <span style={{ fontSize: "0.55rem" }}>{tab.short}</span>
+ {/* Active indicator */}
+ {isActive && (
+ <span
+ className="absolute top-0 left-1/2 -translate-x-1/2 h-px w-8 bg-teal-bio"
+ aria-hidden="true"
+ />
+ )}
+ </button>
+ );
+ })}
+ <a
+ href="/workspace"
+ aria-label="Workspace"
+ className={[
+ "flex flex-col items-center justify-center gap-1 py-3 px-1 min-h-[56px]",
+ "font-mono text-label-sm uppercase tracking-widest transition-colors duration-200 text-teal-bio",
+ ].join(" ")}
+ style={{ touchAction: "manipulation" }}
+ >
+ <svg width={18} height={18} viewBox="0 0 18 18" fill="none" aria-hidden="true">
+ <rect x="2.5" y="3.5" width="13" height="11" stroke="#16e0c8" strokeWidth="1.2" />
+ <rect x="2.5" y="3.5" width="13" height="3" fill="#16e0c8" opacity="0.5" />
+ </svg>
+ <span style={{ fontSize: "0.55rem" }}>WORK</span>
+ </a>
+ </div>
+ </div>
+ );
+}
+
+function TabIcon({ id, active }: { id: MobileTab; active: boolean }) {
+ const color = active ? "#16e0c8" : "#4a525e";
+ const w = 18;
+ const h = 18;
+ if (id === "ask") {
+ return (
+ <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
+ <circle cx="9" cy="9" r="6.5" stroke={color} strokeWidth="1.2" />
+ <circle cx="9" cy="9" r="2.5" fill={color} opacity={active ? 0.8 : 0.4} />
+ </svg>
+ );
+ }
+ if (id === "hits") {
+ return (
+ <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
+ <rect x="3" y="5" width="12" height="1.5" fill={color} />
+ <rect x="3" y="8.5" width="9" height="1.5" fill={color} opacity="0.7" />
+ <rect x="3" y="12" width="6" height="1.5" fill={color} opacity="0.4" />
+ </svg>
+ );
+ }
+ if (id === "agents") {
+ return (
+ <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
+ <circle cx="9" cy="4" r="2" stroke={color} strokeWidth="1.2" />
+ <circle cx="4" cy="13" r="2" stroke={color} strokeWidth="1.2" />
+ <circle cx="14" cy="13" r="2" stroke={color} strokeWidth="1.2" />
+ <line x1="9" y1="6" x2="4" y2="11" stroke={color} strokeWidth="1" opacity="0.5" />
+ <line x1="9" y1="6" x2="14" y2="11" stroke={color} strokeWidth="1" opacity="0.5" />
+ </svg>
+ );
+ }
+ // health
+ return (
+ <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
+ <rect x="2" y="2" width="6" height="6" stroke={color} strokeWidth="1.2" />
+ <rect x="10" y="2" width="6" height="6" stroke={color} strokeWidth="1.2" />
+ <rect x="2" y="10" width="6" height="6" stroke={color} strokeWidth="1.2" />
+ <rect x="10" y="10" width="6" height="6" stroke={color} strokeWidth="1.2" opacity="0.4" />
+ </svg>
+ );
+}
+
+// ─── Desktop three-pane surface ────────────────────────────────────────────
+
+interface SurfaceProps {
+ askData: AskResponse | null;
+ loading: boolean;
+ error: string | null;
+ submit: (q: string) => Promise<void>;
+ onFocusChange: (focused: boolean) => void;
+ healthData: ReturnType<typeof useHealth>["data"];
+ healthLoading: boolean;
+}
+
+function DesktopSurface({ askData, loading, error, submit, onFocusChange }: SurfaceProps) {
+ const ref = useRef<HTMLDivElement>(null);
+ const revealed = useRef(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (prefersReduced) return;
+
+ const panes = el.querySelectorAll<HTMLElement>(".surface-pane");
+ panes.forEach((pane) => {
+ pane.style.opacity = "0";
+ pane.style.transform = "translateY(24px)";
+ });
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && !revealed.current) {
+ revealed.current = true;
+ panes.forEach((pane, i) => {
+ setTimeout(() => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ pane.style.transition = "opacity 0.7s cubic-bezier(0.16,1,0.3,1), transform 0.7s cubic-bezier(0.16,1,0.3,1)";
+ pane.style.opacity = "1";
+ pane.style.transform = "translateY(0)";
+ });
+ });
+ }, i * 100);
+ });
+ }
+ },
+ { threshold: 0.08 }
+ );
+
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ const hits = askData?.hits ?? [];
+
+ return (
+ <section
+ id="surface"
+ ref={ref}
+ aria-label="Darwin query surface"
+ className="hidden lg:block min-h-screen py-20 px-8 relative z-10"
+ >
+ <div className="w-full">
+ <div className="mb-12">
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3">
+ Surface
+ </p>
+ <h2 className="font-display text-display-lg font-bold text-text-primary">
+ Neural interface
+ </h2>
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)_280px] gap-5 min-h-[700px]">
+ <div className="surface-pane">
+ <AgentTree />
+ </div>
+ <div className="surface-pane">
+ <AskBox
+ data={askData}
+ loading={loading}
+ error={error}
+ onSubmit={submit}
+ onFocusChange={onFocusChange}
+ />
+ </div>
+ <div className="surface-pane">
+ <HitsInbox hits={hits} />
+ </div>
+ </div>
+ </div>
+ </section>
+ );
+}
+
+// ─── Mobile surface (tabbed, single-column, thumb-reachable) ───────────────
+
+interface MobileSurfaceProps {
+ askData: AskResponse | null;
+ loading: boolean;
+ error: string | null;
+ submit: (q: string) => Promise<void>;
+ onFocusChange: (focused: boolean) => void;
+ healthData: ReturnType<typeof useHealth>["data"];
+ healthLoading: boolean;
+ activeTab: MobileTab;
+}
+
+function MobileSurface({
+ askData,
+ loading,
+ error,
+ submit,
+ onFocusChange,
+ healthData,
+ healthLoading,
+ activeTab,
+}: MobileSurfaceProps) {
+ const hits = askData?.hits ?? [];
+
+ return (
+ <section
+ id="surface"
+ aria-label="Darwin query surface"
+ className="lg:hidden relative z-10"
+ style={{ paddingBottom: "calc(64px + env(safe-area-inset-bottom, 0px))" }}
+ >
+ {/* Section header - brutalist, editorial */}
+ <div className="px-5 pt-10 pb-6 border-b border-surface-2">
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
+ Surface
+ </p>
+ <h2 className="font-display text-display-lg font-bold text-text-primary leading-none">
+ Neural<br />interface
+ </h2>
+ </div>
+
+ {/* Tab panels */}
+ <div role="tabpanel" id={`panel-${activeTab}`} aria-labelledby={`tab-${activeTab}`}>
+ {activeTab === "ask" && (
+ <div className="mobile-surface-pane">
+ <AskBox
+ data={askData}
+ loading={loading}
+ error={error}
+ onSubmit={submit}
+ onFocusChange={onFocusChange}
+ />
+ </div>
+ )}
+ {activeTab === "hits" && (
+ <div className="mobile-surface-pane">
+ <HitsInbox hits={hits} />
+ </div>
+ )}
+ {activeTab === "agents" && (
+ <div className="mobile-surface-pane">
+ <AgentTree />
+ </div>
+ )}
+ {activeTab === "health" && (
+ <div className="mobile-surface-pane">
+ {healthLoading && !healthData && (
+ <div className="flex items-center gap-3 py-8">
+ <div className="w-2 h-2 rounded-full bg-teal-bio animate-pulse-slow" />
+ <span className="font-mono text-label-sm text-text-muted uppercase tracking-widest">
+ Loading health data...
+ </span>
+ </div>
+ )}
+ {healthData && (
+ <>
+ <div className="mb-6">
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
+ Anatomy
+ </p>
+ <h3 className="font-display text-display-md font-bold text-text-primary">
+ Organism health
+ </h3>
+ <p className="font-ui text-sm text-text-secondary mt-2">
+ Each data source is a living organ.
+ </p>
+ </div>
+ <HealthPanel engines={healthData.engines} ok={healthData.ok} />
+ </>
+ )}
+ {!healthLoading && !healthData && (
+ <div className="py-8">
+ <p className="font-mono text-label-sm text-red-400 uppercase tracking-widest">
+ Health check failed
+ </p>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ </section>
+ );
+}
+
+// ─── Desktop Health section ────────────────────────────────────────────────
+
+function DesktopHealthSection({ healthData }: { healthData: ReturnType<typeof useHealth>["data"] }) {
+ if (!healthData) return null;
+
+ return (
+ <section
+ id="health"
+ aria-label="Darwin organism health"
+ className="hidden lg:block py-16 px-6 relative z-10"
+ >
+ <div className="max-w-7xl mx-auto">
+ <div className="mb-8">
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
+ Anatomy
+ </p>
+ <h2 className="font-display text-display-lg font-bold text-text-primary">
+ Organism health
+ </h2>
+ <p className="font-ui text-sm text-text-secondary mt-2 max-w-md">
+ Each data source is a living organ. Stale nodes wither in the hero above.
+ </p>
+ </div>
+ <div className="max-w-md">
+ <HealthPanel engines={healthData.engines} ok={healthData.ok} />
+ </div>
+ </div>
+ </section>
+ );
+}
+
+// ─── Footer ────────────────────────────────────────────────────────────────
+
+function Footer() {
+ return (
+ <footer className="py-8 px-6 border-t border-surface-2 relative z-10 hidden lg:block">
+ <div className="max-w-7xl mx-auto flex items-center justify-between">
+ <span className="font-mono text-label-sm text-text-muted">
+ Darwin - CT215 homelab
+ </span>
+ <span className="font-mono text-label-sm text-text-muted">
+ {new Date().getFullYear()}
+ </span>
+ </div>
+ </footer>
+ );
+}
+
+// ─── Page composition ──────────────────────────────────────────────────────
+
+export default function Page() {
+ const { data: healthData, loading: healthLoading } = useHealth(30_000);
+ const { data: askData, loading, error, submit } = useAsk();
+ const [focusMode, setFocusMode] = useState(false);
+ const [mobileTab, setMobileTab] = useState<MobileTab>("ask");
+
+ // When ask returns data on mobile, switch to hits tab automatically
+ const handleSubmit = useCallback(
+ async (q: string) => {
+ await submit(q);
+ },
+ [submit]
+ );
+
+ // After ask resolves, auto-switch to hits on mobile if there are hits
+ useEffect(() => {
+ if (askData && askData.hits.length > 0 && typeof window !== "undefined") {
+ const isMobile = window.innerWidth < 1024;
+ if (isMobile) {
+ // Small delay so user sees the answer populate first
+ setTimeout(() => setMobileTab("hits"), 1200);
+ }
+ }
+ }, [askData]);
+
+ const liveOk = healthData ? healthData.ok : null;
+
+ return (
+ <main>
+ {/* Global aurora blob - GPU-lighter on mobile (no blur filter) */}
+ <div className="aurora-blob" aria-hidden="true" />
+
+ <Nav liveOk={liveOk} />
+
+ {/* Hero - R3F organism fills the section; wordmark anchors lower third.
+ .hero-organism is a clipped stacking context so the canvas stays inside
+ the hero and never overlays the Hits/Agents/Health views below. */}
+ <section className="hero-section" aria-label="Darwin organism hero">
+ <div className="hero-organism" aria-hidden="true">
+ <OrganismBoundary>
+ <DarwinOrganism healthData={healthData} focusMode={focusMode} />
+ </OrganismBoundary>
+ </div>
+ <div className="relative z-10 flex flex-col items-center gap-4 mt-auto mb-20">
+ <Wordmark />
+ </div>
+ <ScrollHint />
+ </section>
+
+ {/* Desktop three-pane surface */}
+ <DesktopSurface
+ askData={askData}
+ loading={loading}
+ error={error}
+ submit={handleSubmit}
+ onFocusChange={setFocusMode}
+ healthData={healthData}
+ healthLoading={healthLoading}
+ />
+
+ {/* Mobile tabbed surface */}
+ <MobileSurface
+ askData={askData}
+ loading={loading}
+ error={error}
+ submit={handleSubmit}
+ onFocusChange={setFocusMode}
+ healthData={healthData}
+ healthLoading={healthLoading}
+ activeTab={mobileTab}
+ />
+
+ {/* Desktop health section */}
+ <DesktopHealthSection healthData={healthData} />
+
+ <Footer />
+
+ {/* Mobile bottom tab bar - fixed, thumb-reachable */}
+ <MobileTabBar
+ active={mobileTab}
+ onChange={setMobileTab}
+ hitCount={askData?.hits.length ?? 0}
+ healthOk={liveOk}
+ />
+ </main>
+ );
+}
face/src/app/workspace/page.tsx +18 -0
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+import dynamic from "next/dynamic";
+import "@xterm/xterm/css/xterm.css";
+
+const WorkspaceClient = dynamic(
+ () => import("@/components/workspace/WorkspaceClient"),
+ { ssr: false }
+);
+
+export const metadata: Metadata = {
+ title: "Darwin - Workspace",
+ description: "Team-up multi-agent terminal workspace.",
+ robots: "noindex, nofollow",
+};
+
+export default function WorkspacePage() {
+ return <WorkspaceClient />;
+}
face/src/components/health/HealthPanel.tsx +94 -0
@@ -0,0 +1,94 @@
+"use client";
+
+import type { HealthEngine } from "@/lib/types";
+
+interface HealthPanelProps {
+ engines: HealthEngine[];
+ ok: boolean;
+}
+
+function stalenessLabel(h: number): string {
+ if (h < 1) return `${Math.round(h * 60)}m`;
+ if (h < 24) return `${h.toFixed(1)}h`;
+ return `${Math.round(h / 24)}d`;
+}
+
+function stalenessClass(h: number, status: "up" | "down"): string {
+ if (status === "down") return "text-red-400";
+ if (h < 2) return "text-teal-bio";
+ if (h < 12) return "text-text-secondary";
+ return "text-text-muted";
+}
+
+function EngineRow({ engine }: { engine: HealthEngine }) {
+ const isUp = engine.status === "up";
+ const stale = engine.staleness_h > 12;
+
+ return (
+ <div className="flex items-center gap-3 py-2 border-b border-surface-2 last:border-0">
+ {/* Status dot */}
+ <div
+ className={[
+ "w-2 h-2 rounded-full flex-shrink-0",
+ isUp && !stale ? "bg-teal-bio animate-pulse-slow" : "",
+ isUp && stale ? "bg-text-muted" : "",
+ !isUp ? "bg-red-500" : "",
+ ].join(" ")}
+ />
+
+ {/* Engine name */}
+ <span
+ className={[
+ "font-mono text-label-sm uppercase tracking-widest flex-1",
+ isUp ? "text-text-primary" : "text-text-muted",
+ ].join(" ")}
+ >
+ {engine.engine}
+ </span>
+
+ {/* Staleness */}
+ <span className={`font-mono text-label-sm ${stalenessClass(engine.staleness_h, engine.status)}`}>
+ {isUp ? stalenessLabel(engine.staleness_h) : "down"}
+ </span>
+ </div>
+ );
+}
+
+export default function HealthPanel({ engines, ok }: HealthPanelProps) {
+ const upCount = engines.filter((e) => e.status === "up").length;
+ const freshCount = engines.filter((e) => e.status === "up" && e.staleness_h < 2).length;
+
+ return (
+ <div className="border border-surface-2 p-4">
+ {/* Header */}
+ <div className="flex items-center justify-between mb-4">
+ <div className="flex items-center gap-2">
+ <div className={`w-2 h-2 rounded-full ${ok ? "bg-teal-bio animate-pulse-slow" : "bg-red-500"}`} />
+ <span className="font-mono text-label-sm uppercase tracking-widest text-text-primary">
+ Organism health
+ </span>
+ </div>
+ <span className="font-mono text-label-sm text-text-muted">
+ {upCount}/{engines.length} up
+ </span>
+ </div>
+
+ {/* Engine list */}
+ <div>
+ {engines.map((eng) => (
+ <EngineRow key={eng.engine} engine={eng} />
+ ))}
+ </div>
+
+ {/* Summary */}
+ <div className="mt-4 pt-3 border-t border-surface-2 flex justify-between items-center">
+ <span className="font-mono text-label-sm text-text-muted">
+ {freshCount} nodes fresh
+ </span>
+ <span className={`font-mono text-label-sm ${ok ? "text-teal-bio" : "text-red-400"}`}>
+ {ok ? "HEALTHY" : "DEGRADED"}
+ </span>
+ </div>
+ </div>
+ );
+}
face/src/components/organism/DarwinOrganism.tsx +342 -0
@@ -0,0 +1,342 @@
+"use client";
+
+import { useRef, useMemo, useEffect, useState, useCallback } from "react";
+import { Canvas, useFrame, useThree } from "@react-three/fiber";
+import { Text, Line } from "@react-three/drei";
+import * as THREE from "three";
+import type { HealthResponse, DataSource } from "@/lib/types";
+
+// ─── Data source positions (cluster layout) ────────────────────────────────
+
+const SOURCE_CONFIG: Record<DataSource, { label: string; basePos: [number, number, number] }> = {
+ proxmox: { label: "PROXMOX", basePos: [-4.0, 2.2, -0.4] },
+ "white-pc": { label: "WHITE-PC", basePos: [ 3.8, 2.6, 0.2] },
+ "3060": { label: "RTX 3060", basePos: [ 2.8, -1.8, 0.7] },
+ "browser-history":{ label: "BROWSER", basePos: [ 0.5, 3.8, 0.3] },
+ agents: { label: "AGENTS", basePos: [-1.8, -3.4, 0.0] },
+ rag: { label: "RAG", basePos: [-3.6, -0.6, 0.9] },
+ cbm: { label: "CBM", basePos: [ 2.4, 1.0, -1.0] },
+ serena: { label: "SERENA", basePos: [-1.0, 2.6, -1.6] },
+ memgraph: { label: "MEMGRAPH", basePos: [ 0.3, -2.6, -1.0] },
+ inbox: { label: "INBOX", basePos: [ 4.4, -0.6, -0.3] },
+};
+
+const SOURCES = Object.keys(SOURCE_CONFIG) as DataSource[];
+
+// Connections between nodes
+const CONNECTIONS: [DataSource, DataSource][] = [
+ ["rag", "proxmox"],
+ ["rag", "agents"],
+ ["rag", "cbm"],
+ ["cbm", "serena"],
+ ["cbm", "proxmox"],
+ ["agents", "memgraph"],
+ ["agents", "inbox"],
+ ["serena", "proxmox"],
+ ["memgraph", "rag"],
+ ["inbox", "agents"],
+ ["3060", "agents"],
+ ["white-pc", "rag"],
+ ["browser-history", "memgraph"],
+];
+
+// ─── Health color helpers ──────────────────────────────────────────────────
+
+const TEAL = new THREE.Color("#16e0c8");
+const TEAL_DIM = new THREE.Color("#0d9e8e");
+const GREY = new THREE.Color("#3a3a3a");
+const DEAD = new THREE.Color("#1a1a1a");
+
+function healthColor(staleness_h: number, status: "up" | "down"): THREE.Color {
+ if (status === "down") return DEAD;
+ if (staleness_h < 2) return TEAL;
+ if (staleness_h < 12) return TEAL_DIM;
+ if (staleness_h < 24) return GREY;
+ return DEAD;
+}
+
+function healthScale(staleness_h: number, status: "up" | "down"): number {
+ if (status === "down") return 0.3;
+ if (staleness_h > 24) return 0.4;
+ if (staleness_h > 12) return 0.7;
+ return 1.0;
+}
+
+// ─── Node component ────────────────────────────────────────────────────────
+
+interface NodeProps {
+ id: DataSource;
+ targetPos: [number, number, number];
+ color: THREE.Color;
+ scale: number;
+ label: string;
+ cursorWorld: React.MutableRefObject<THREE.Vector3>;
+ spawnProgress: number;
+ isStale: boolean;
+ collapse: React.MutableRefObject<number>;
+}
+
+function OrgNode({ id, targetPos, color, scale, label, cursorWorld, spawnProgress, isStale, collapse }: NodeProps) {
+ const meshRef = useRef<THREE.Mesh>(null);
+ const currentPos = useRef(new THREE.Vector3(0, 0, 0));
+ const target = useMemo(() => new THREE.Vector3(...targetPos), [targetPos]);
+ const glowRef = useRef<THREE.PointLight>(null);
+ const timeRef = useRef(Math.random() * Math.PI * 2); // random phase for breathe
+
+ useFrame((_, delta) => {
+ if (!meshRef.current) return;
+ timeRef.current += delta;
+
+ // Lerp position toward target (spawn animation + cursor attraction)
+ const attraction = new THREE.Vector3().copy(cursorWorld.current).sub(target);
+ const dist = attraction.length();
+ const pull = dist < 4 ? (1 - dist / 4) * 0.4 : 0;
+
+ const pullTarget = new THREE.Vector3()
+ .copy(target)
+ .addScaledVector(attraction.normalize(), pull * -1); // pull toward cursor
+
+ // Focus collapse: organism folds inward toward center ("the brain becomes a question")
+ pullTarget.multiplyScalar(1 - collapse.current * 0.82);
+
+ // Smooth lerp - use 0.08 for buttery 60fps feel
+ currentPos.current.lerp(pullTarget.multiplyScalar(spawnProgress), 0.08);
+ meshRef.current.position.copy(currentPos.current);
+
+ // Breathe scale - larger base size, gentle breathe
+ const breathe = 1 + Math.sin(timeRef.current * 0.7 + timeRef.current) * 0.05;
+ const finalScale = scale * breathe * spawnProgress * (1 - collapse.current * 0.35);
+ meshRef.current.scale.setScalar(finalScale);
+
+ // Glow intensity
+ if (glowRef.current) {
+ const intensity = isStale ? 0 : 0.5 + Math.sin(timeRef.current * 1.2) * 0.2;
+ glowRef.current.intensity = intensity * spawnProgress;
+ }
+ });
+
+ return (
+ <group>
+ <mesh ref={meshRef}>
+ {/* Larger spheres - 0.32 radius vs old 0.18 - much more visible */}
+ <sphereGeometry args={[0.32, 24, 24]} />
+ <meshStandardMaterial
+ color={color}
+ emissive={color}
+ emissiveIntensity={isStale ? 0.08 : 0.8}
+ roughness={0.15}
+ metalness={0.4}
+ />
+ </mesh>
+ <pointLight
+ ref={glowRef}
+ color={color}
+ intensity={0}
+ distance={3.5}
+ position={[...targetPos] as [number, number, number]}
+ />
+ <Text
+ position={[targetPos[0], targetPos[1] + 0.55, targetPos[2]]}
+ fontSize={0.15}
+ letterSpacing={0.06}
+ color={isStale ? "#3a3a3a" : "#16e0c8"}
+ anchorX="center"
+ anchorY="bottom"
+ outlineWidth={0}
+ material-toneMapped={false}
+ >
+ {label}
+ </Text>
+ </group>
+ );
+}
+
+// ─── Organism scene ────────────────────────────────────────────────────────
+
+interface SceneProps {
+ healthData: HealthResponse | null;
+ focusMode: boolean;
+}
+
+function OrganismScene({ healthData, focusMode }: SceneProps) {
+ const { camera, gl } = useThree();
+ const cursorWorld = useRef(new THREE.Vector3(0, 0, 0));
+ const [spawnProgress, setSpawnProgress] = useState(0);
+ const startTime = useRef(Date.now());
+ const collapse = useRef(0);
+ const centerGlowRef = useRef<THREE.PointLight>(null);
+
+ useEffect(() => {
+ // Pull camera closer so organism fills the hero canvas
+ camera.position.set(0, 0, 6);
+ }, [camera]);
+
+ // Spawn animation: ramp from 0 → 1 over 1.2s with expo-out
+ useEffect(() => {
+ let rafId: number;
+ function tick() {
+ const elapsed = (Date.now() - startTime.current) / 1200;
+ // expo-out: 1 - 2^(-10t)
+ const t = Math.min(elapsed, 1);
+ const eased = t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
+ setSpawnProgress(eased);
+ if (t < 1) rafId = requestAnimationFrame(tick);
+ }
+ rafId = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(rafId);
+ }, []);
+
+ // Mouse → world position
+ useEffect(() => {
+ const canvas = gl.domElement;
+ function onMouseMove(e: MouseEvent) {
+ const rect = canvas.getBoundingClientRect();
+ const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ const ny = -((e.clientY - rect.top) / rect.height) * 2 + 1;
+ // Unproject at z=0
+ const vec = new THREE.Vector3(nx, ny, 0.5);
+ vec.unproject(camera);
+ vec.sub(camera.position).normalize();
+ const dist = -camera.position.z / vec.z;
+ const pos = new THREE.Vector3().addVectors(camera.position, vec.multiplyScalar(dist));
+ cursorWorld.current.copy(pos);
+ }
+ canvas.addEventListener("mousemove", onMouseMove, { passive: true });
+ return () => canvas.removeEventListener("mousemove", onMouseMove);
+ }, [camera, gl]);
+
+ // Build health map
+ const healthMap = useMemo(() => {
+ const map = new Map<string, { staleness_h: number; status: "up" | "down" }>();
+ if (healthData) {
+ for (const eng of healthData.engines) {
+ map.set(eng.engine, { staleness_h: eng.staleness_h, status: eng.status });
+ }
+ }
+ return map;
+ }, [healthData]);
+
+ // Slow rotation group + focus-collapse easing + center glow swell
+ const groupRef = useRef<THREE.Group>(null);
+ useFrame((_, delta) => {
+ if (groupRef.current) {
+ groupRef.current.rotation.y += delta * 0.04;
+ groupRef.current.rotation.x += delta * 0.01;
+ }
+ // Ease collapse toward focus target (expo-ish via frame-rate-independent lerp)
+ const collapseTarget = focusMode ? 1 : 0;
+ collapse.current += (collapseTarget - collapse.current) * Math.min(1, delta * 4.5);
+ if (centerGlowRef.current) {
+ centerGlowRef.current.intensity = (1.5 + collapse.current * 2.5) * spawnProgress;
+ }
+ });
+
+ return (
+ <>
+ {/* Ambient + key lighting - boosted for bioluminescent pop */}
+ <ambientLight intensity={0.25} />
+ <pointLight position={[0, 0, 4]} intensity={0.6} color="#ffffff" />
+ <pointLight position={[-4, 3, 2]} intensity={0.3} color="#16e0c8" distance={12} />
+ <pointLight ref={centerGlowRef} position={[0, 0, 0]} intensity={2.0} color="#16e0c8" distance={10} />
+
+ <group ref={groupRef}>
+ {/* Connection lines */}
+ {CONNECTIONS.map(([a, b], i) => {
+ const aPos = SOURCE_CONFIG[a].basePos;
+ const bPos = SOURCE_CONFIG[b].basePos;
+ const aHealth = healthMap.get(a);
+ const bHealth = healthMap.get(b);
+ const bothHealthy =
+ (!aHealth || aHealth.staleness_h < 12) &&
+ (!bHealth || bHealth.staleness_h < 12);
+ return (
+ <Line
+ key={i}
+ points={[aPos, bPos]}
+ color={bothHealthy ? "#16e0c8" : "#222"}
+ lineWidth={bothHealthy ? 0.8 : 0.4}
+ transparent
+ opacity={bothHealthy ? 0.35 * spawnProgress : 0.1 * spawnProgress}
+ />
+ );
+ })}
+
+ {/* Nodes */}
+ {SOURCES.map((id) => {
+ const cfg = SOURCE_CONFIG[id];
+ const h = healthMap.get(id) ?? { staleness_h: 0, status: "up" as const };
+ return (
+ <OrgNode
+ key={id}
+ id={id}
+ targetPos={cfg.basePos}
+ color={healthColor(h.staleness_h, h.status)}
+ scale={healthScale(h.staleness_h, h.status)}
+ label={cfg.label}
+ cursorWorld={cursorWorld}
+ spawnProgress={spawnProgress}
+ isStale={h.staleness_h > 12 || h.status === "down"}
+ collapse={collapse}
+ />
+ );
+ })}
+ </group>
+ </>
+ );
+}
+
+// ─── Public component ──────────────────────────────────────────────────────
+
+interface DarwinOrganismProps {
+ healthData: HealthResponse | null;
+ focusMode?: boolean;
+}
+
+// RenderGate: freezes the render loop when the canvas is scrolled off-screen,
+// throttles invalidate() to ~30fps while visible. Requires frameloop="demand".
+function RenderGate() {
+ const invalidate = useThree((s) => s.invalidate);
+ const gl = useThree((s) => s.gl);
+ const visibleRef = useRef(true);
+ const accRef = useRef(0);
+
+ useEffect(() => {
+ const el = gl.domElement;
+ const io = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ visibleRef.current = entry.isIntersecting;
+ if (entry.isIntersecting) invalidate();
+ },
+ { threshold: 0 }
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, [gl, invalidate]);
+
+ useFrame((_, delta) => {
+ if (!visibleRef.current) return;
+ accRef.current += delta;
+ if (accRef.current >= 1 / 30) {
+ accRef.current = 0;
+ invalidate();
+ }
+ });
+
+ return null;
+}
+
+export default function DarwinOrganism({ healthData, focusMode = false }: DarwinOrganismProps) {
+ return (
+ <Canvas
+ frameloop="demand"
+ dpr={[1, 2]}
+ gl={{ antialias: true, alpha: true }}
+ style={{ background: "transparent" }}
+ camera={{ fov: 60, near: 0.1, far: 100 }}
+ >
+ <RenderGate />
+ <OrganismScene healthData={healthData} focusMode={focusMode} />
+ </Canvas>
+ );
+}
face/src/components/organism/OrganismBoundary.tsx +31 -0
@@ -0,0 +1,31 @@
+"use client";
+
+import { Component, ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+}
+
+class OrganismBoundary extends Component<Props, State> {
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(): State {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error) {}
+
+ render() {
+ if (this.state.hasError) return null;
+ return this.props.children;
+ }
+}
+
+export default OrganismBoundary;
face/src/components/surface/AgentTree.tsx +735 -0
@@ -0,0 +1,735 @@
+"use client";
+
+import { useEffect, useRef, useState, useCallback, useMemo } from "react";
+
+// --- Live Deck types (contract §1) ---
+interface DeckNode {
+ agent_id: string;
+ parent_id: string | null;
+ label: string;
+ model: string;
+ status: string; // running|done|error|paused
+ tokens: number;
+ host: string;
+ session: string;
+ spawned_at?: string;
+ last_activity: string;
+ current_tool: string | null;
+ task_count?: number;
+}
+
+interface TreeSnapshot {
+ ts: string;
+ nodes: DeckNode[];
+ active_count: number;
+ total_tokens: number;
+}
+
+type UIStatus = "active" | "idle" | "done" | "error";
+
+interface UINode {
+ id: string;
+ parentId: string | null;
+ label: string; // cleaned, full
+ short: string; // truncated for the canvas
+ model: string;
+ host: string;
+ session: string;
+ status: UIStatus;
+ rawStatus: string;
+ tokens: number;
+ tool: string | null;
+ lastActivity: string; // relative
+ lastActivityRaw: string;
+ taskCount: number;
+ isRoot: boolean;
+}
+
+// --- Mock fallback (used when Deck is down) ---
+const MOCK_DECK: DeckNode[] = [
+ { agent_id: "root", parent_id: null, label: "DARWIN", model: "system", status: "running", tokens: 0, host: "ct215", session: "system", last_activity: new Date().toISOString(), current_tool: null, task_count: 4 },
+ { agent_id: "claude", parent_id: "root", label: "Researching homelab architecture and indexing layers", model: "sonnet", status: "running", tokens: 44508, host: "ct215", session: "demo-1", last_activity: new Date().toISOString(), current_tool: "WebSearch", task_count: 2 },
+ { agent_id: "codex", parent_id: "root", label: "Refactor the deck stream protocol", model: "codex", status: "done", tokens: 12044, host: "ct241", session: "demo-2", last_activity: new Date(Date.now() - 4 * 60_000).toISOString(), current_tool: null, task_count: 0 },
+ { agent_id: "glm", parent_id: "root", label: "Maintain oversight site and blog", model: "glm", status: "running", tokens: 8800, host: "ct247", session: "demo-3", last_activity: new Date(Date.now() - 38_000).toISOString(), current_tool: "Edit", task_count: 1 },
+ { agent_id: "claude-2", parent_id: "claude", label: "Map what indexing already exists in the homelab", model: "sonnet", status: "done", tokens: 58147, host: "ct215", session: "demo-1", last_activity: new Date(Date.now() - 2 * 3600_000).toISOString(), current_tool: null, task_count: 6 },
+ { agent_id: "probe-1", parent_id: "glm", label: "Probe candidate vuln surface", model: "glm", status: "done", tokens: 3200, host: "ct247", session: "demo-3", last_activity: new Date(Date.now() - 60_000).toISOString(), current_tool: null, task_count: 0 },
+];
+
+// --- Deck URL ---
+// Deck (agent tree stream) lives on CT215 :9893 and serves CORS '*'.
+const DECK_WS_URL =
+ process.env.NEXT_PUBLIC_DECK_URL || "ws://REDACTED-IP:9893/stream";
+const DECK_HTTP_URL = DECK_WS_URL.replace(/^ws/, "http").replace("/stream", "");
+
+// A node counts as "active" only if it is running AND was active recently.
+const ACTIVE_WINDOW_MS = 10 * 60_000;
+
+function relativeTime(iso: string): string {
+ try {
+ const diff = Date.now() - new Date(iso).getTime();
+ const s = Math.floor(diff / 1000);
+ if (s < 5) return "now";
+ if (s < 60) return `${s}s ago`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m ago`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h}h ago`;
+ return `${Math.floor(h / 24)}d ago`;
+ } catch {
+ return "";
+ }
+}
+
+// Strip markdown / collapse whitespace from a label; turn long task prompts into
+// a readable lead phrase.
+function cleanLabel(raw: string): string {
+ return raw
+ .replace(/^#+\s*/g, "")
+ .replace(/[*_`>]/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function truncate(s: string, n: number): string {
+ return s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s;
+}
+
+function toUINode(n: DeckNode): UINode {
+ const recent =
+ n.last_activity &&
+ Date.now() - new Date(n.last_activity).getTime() < ACTIVE_WINDOW_MS;
+ let status: UIStatus;
+ if (n.status === "error") status = "error";
+ else if (n.status === "running" && recent) status = "active";
+ else if (n.status === "running") status = "idle";
+ else status = "done";
+
+ const isRoot = n.parent_id === null;
+ const clean = cleanLabel(n.label);
+ return {
+ id: n.agent_id,
+ parentId: n.parent_id,
+ label: clean,
+ short: truncate(clean, isRoot ? 16 : 22),
+ model: n.model,
+ host: n.host,
+ session: n.session,
+ status,
+ rawStatus: n.status,
+ tokens: n.tokens ?? 0,
+ tool: n.current_tool,
+ lastActivity: n.last_activity ? relativeTime(n.last_activity) : "",
+ lastActivityRaw: n.last_activity,
+ taskCount: n.task_count ?? 0,
+ isRoot,
+ };
+}
+
+const STATUS_COLOR: Record<UIStatus, string> = {
+ active: "#16e0c8",
+ idle: "#0d9e8e",
+ done: "#3a4048",
+ error: "#ef4444",
+};
+
+const STATUS_TEXT: Record<UIStatus, string> = {
+ active: "text-teal-bio",
+ idle: "text-teal-dim",
+ done: "text-text-muted",
+ error: "text-red-400",
+};
+
+// ─── Tidy-ish tree layout (deterministic, non-overlapping) ──────────────────
+
+interface Pos {
+ id: string;
+ x: number;
+ y: number;
+ node: UINode;
+}
+
+const COL_W = 230; // horizontal gap between depth levels
+const ROW_H = 46; // vertical gap between leaves
+const PAD = 40;
+
+// Horizontal layout: depth → x, leaf-order → y. Parents centered on children.
+function layoutTree(nodes: UINode[]): { positions: Pos[]; width: number; height: number } {
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const children = new Map<string | null, UINode[]>();
+ for (const n of nodes) {
+ const list = children.get(n.parentId) ?? [];
+ list.push(n);
+ children.set(n.parentId, list);
+ }
+ // Stable order: active first, then by last activity desc
+ for (const list of Array.from(children.values())) {
+ list.sort((a, b) => {
+ if (a.status === "active" && b.status !== "active") return -1;
+ if (b.status === "active" && a.status !== "active") return 1;
+ return (b.lastActivityRaw || "").localeCompare(a.lastActivityRaw || "");
+ });
+ }
+
+ const positions: Pos[] = [];
+ let leafCursor = 0;
+
+ function walk(id: string, depth: number): number {
+ const node = byId.get(id)!;
+ const kids = (children.get(id) ?? []).filter((k) => byId.has(k.id));
+ let y: number;
+ if (kids.length === 0) {
+ y = leafCursor * ROW_H + PAD;
+ leafCursor += 1;
+ } else {
+ const ys = kids.map((k) => walk(k.id, depth + 1));
+ y = (ys[0] + ys[ys.length - 1]) / 2;
+ }
+ positions.push({ id, x: depth * COL_W + PAD, y, node });
+ return y;
+ }
+
+ const roots = nodes.filter((n) => n.parentId === null || !byId.has(n.parentId));
+ for (const r of roots) walk(r.id, 0);
+
+ const width = Math.max(COL_W, ...positions.map((p) => p.x)) + COL_W;
+ const height = Math.max(ROW_H, ...positions.map((p) => p.y)) + PAD;
+ return { positions, width, height };
+}
+
+// ─── Inspect panel ───────────────────────────────────────────────────────────
+
+function InspectPanel({
+ node,
+ onClose,
+ onAttach,
+}: {
+ node: UINode;
+ onClose: () => void;
+ onAttach: (n: UINode) => void;
+}) {
+ const canAttach = node.status === "active" || node.status === "idle";
+ const summary = node.tool
+ ? `Currently using ${node.tool}.`
+ : node.status === "active"
+ ? "Working - no tool call in flight."
+ : node.status === "idle"
+ ? "Session open but quiet."
+ : node.status === "error"
+ ? "Ended in an error state."
+ : "Finished its task.";
+
+ return (
+ <div
+ className="absolute inset-x-0 bottom-0 z-20 border-t border-teal-bio/25"
+ style={{
+ background: "rgba(8,10,12,0.97)",
+ backdropFilter: "blur(6px)",
+ animation: "sheet-up 0.32s cubic-bezier(0.16,1,0.3,1)",
+ maxHeight: "62%",
+ overflowY: "auto",
+ }}
+ role="dialog"
+ aria-label={`Inspect ${node.label}`}
+ >
+ <div className="px-4 pt-3.5 pb-4">
+ {/* header row */}
+ <div className="flex items-start justify-between gap-3 mb-3">
+ <div className="min-w-0">
+ <div className="flex items-center gap-2 mb-1">
+ <span
+ className="w-2 h-2 rounded-full shrink-0"
+ style={{ background: STATUS_COLOR[node.status], boxShadow: node.status === "active" ? `0 0 8px ${STATUS_COLOR[node.status]}` : "none" }}
+ />
+ <span className={`font-mono text-label-sm uppercase tracking-widest ${STATUS_TEXT[node.status]}`}>
+ {node.status}
+ </span>
+ </div>
+ <p className="font-ui text-sm text-text-primary leading-snug">
+ {node.label}
+ </p>
+ </div>
+ <button
+ onClick={onClose}
+ aria-label="Close inspector"
+ className="shrink-0 w-8 h-8 grid place-items-center text-text-muted hover:text-teal-bio transition-colors"
+ style={{ touchAction: "manipulation" }}
+ >
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
+ <path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="square" />
+ </svg>
+ </button>
+ </div>
+
+ {/* meta grid */}
+ <div className="grid grid-cols-2 gap-x-4 gap-y-2.5 mb-3">
+ <Meta k="Host" v={node.host} />
+ <Meta k="Model" v={node.model} />
+ <Meta k="Tokens" v={node.tokens > 0 ? node.tokens.toLocaleString() : "-"} mono />
+ <Meta k="Last active" v={node.lastActivity || "-"} />
+ <Meta k="Tool" v={node.tool || "-"} />
+ <Meta k="Tasks" v={String(node.taskCount)} />
+ </div>
+
+ {/* summary */}
+ <div className="border-l-2 border-teal-bio/40 pl-3 py-1.5 mb-3.5">
+ <p className="font-ui text-xs text-text-secondary leading-relaxed">{summary}</p>
+ </div>
+
+ {/* action */}
+ {canAttach ? (
+ <button
+ onClick={() => onAttach(node)}
+ className="font-display font-bold uppercase w-full"
+ style={{
+ height: 44,
+ fontSize: "0.78rem",
+ letterSpacing: "0.08em",
+ color: "#050607",
+ background: "#16e0c8",
+ border: "none",
+ transition: "filter 0.2s",
+ touchAction: "manipulation",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.1)")}
+ onMouseLeave={(e) => (e.currentTarget.style.filter = "brightness(1)")}
+ >
+ Attach in workspace ▸
+ </button>
+ ) : (
+ <div
+ className="font-mono uppercase text-center"
+ style={{ height: 44, lineHeight: "44px", fontSize: "0.66rem", letterSpacing: "0.08em", color: "#4a525e", border: "1px solid rgba(22,224,200,0.1)" }}
+ >
+ Session ended - read-only
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function Meta({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
+ return (
+ <div className="min-w-0">
+ <p className="font-mono uppercase" style={{ fontSize: "0.5rem", letterSpacing: "0.12em", color: "#4a525e" }}>
+ {k}
+ </p>
+ <p className={`truncate text-text-secondary ${mono ? "font-mono" : "font-ui"}`} style={{ fontSize: "0.74rem" }} title={v}>
+ {v}
+ </p>
+ </div>
+ );
+}
+
+// ─── Main component ────────────────────────────────────────────────────────────
+
+type DeckMode = "live" | "mock" | "connecting";
+
+export default function AgentTree() {
+ const [agents, setAgents] = useState<UINode[]>(() => MOCK_DECK.map(toUINode));
+ const [mode, setMode] = useState<DeckMode>("connecting");
+ const [totalTokens, setTotalTokens] = useState(0);
+ const [selected, setSelected] = useState<string | null>(null);
+ const [hovered, setHovered] = useState<string | null>(null);
+
+ const wsRef = useRef<WebSocket | null>(null);
+ const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+ // viewport transform (pan/zoom)
+ const wrapRef = useRef<HTMLDivElement>(null);
+ const [view, setView] = useState({ x: 0, y: 0, k: 1 });
+ const dragRef = useRef<{ x: number; y: number; vx: number; vy: number; moved: boolean } | null>(null);
+ const pinchRef = useRef<{ d: number; k: number } | null>(null);
+ const fittedRef = useRef(false);
+
+ const applySnapshot = useCallback((snap: TreeSnapshot) => {
+ setAgents(snap.nodes.map(toUINode));
+ setTotalTokens(snap.total_tokens ?? 0);
+ setMode("live");
+ }, []);
+
+ const refetch = useCallback(() => {
+ fetch(`${DECK_HTTP_URL}/tree`)
+ .then((r) => r.json())
+ .then((snap: TreeSnapshot) => applySnapshot(snap))
+ .catch(() => {});
+ }, [applySnapshot]);
+
+ const connectWs = useCallback(() => {
+ if (wsRef.current) { try { wsRef.current.close(); } catch {} }
+ let ws: WebSocket;
+ try {
+ ws = new WebSocket(DECK_WS_URL);
+ } catch {
+ setMode("mock");
+ return;
+ }
+ wsRef.current = ws;
+ setMode("connecting");
+ ws.onmessage = (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ if (data.type === "snapshot" || data.nodes) applySnapshot(data as TreeSnapshot);
+ else if (data.type === "ping") { /* no-op */ }
+ else refetch();
+ } catch { /* ignore */ }
+ };
+ ws.onerror = () => { setMode("mock"); };
+ ws.onclose = () => {
+ if (retryRef.current) clearTimeout(retryRef.current);
+ retryRef.current = setTimeout(connectWs, 5000);
+ };
+ }, [applySnapshot, refetch]);
+
+ useEffect(() => {
+ connectWs();
+ refetch();
+ return () => {
+ if (retryRef.current) clearTimeout(retryRef.current);
+ if (wsRef.current) try { wsRef.current.close(); } catch {}
+ };
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const { positions, width, height } = useMemo(() => layoutTree(agents), [agents]);
+ const posMap = useMemo(() => new Map(positions.map((p) => [p.id, p])), [positions]);
+
+ const counts = useMemo(() => {
+ const c = { active: 0, idle: 0, done: 0, error: 0 };
+ for (const a of agents) c[a.status] += 1;
+ return c;
+ }, [agents]);
+
+ // Fit-to-view: compute transform that frames the whole tree in the wrapper.
+ const fitToView = useCallback(() => {
+ const wrap = wrapRef.current;
+ if (!wrap || positions.length === 0) return;
+ const cw = wrap.clientWidth;
+ const ch = wrap.clientHeight;
+ if (cw === 0 || ch === 0) return;
+ const k = Math.min(cw / (width + PAD), ch / (height + PAD), 1.2);
+ const x = (cw - width * k) / 2;
+ const y = (ch - height * k) / 2;
+ setView({ x, y, k: Math.max(0.25, k) });
+ }, [positions.length, width, height]);
+
+ // Fit once on first real layout, and on resize.
+ useEffect(() => {
+ if (!fittedRef.current && positions.length > 0) {
+ fittedRef.current = true;
+ requestAnimationFrame(fitToView);
+ }
+ }, [positions.length, fitToView]);
+
+ useEffect(() => {
+ const onResize = () => fitToView();
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, [fitToView]);
+
+ // --- pan (pointer) ---
+ const onPointerDown = (e: React.PointerEvent) => {
+ if (e.button !== 0 && e.pointerType === "mouse") return;
+ (e.target as Element).setPointerCapture?.(e.pointerId);
+ dragRef.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false };
+ };
+ const onPointerMove = (e: React.PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ const dx = e.clientX - d.x;
+ const dy = e.clientY - d.y;
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) d.moved = true;
+ setView((v) => ({ ...v, x: d.vx + dx, y: d.vy + dy }));
+ };
+ const onPointerUp = (e: React.PointerEvent) => {
+ (e.target as Element).releasePointerCapture?.(e.pointerId);
+ const d = dragRef.current;
+ dragRef.current = null;
+ // A click (not a pan) → hit-test the nearest node under the cursor and
+ // toggle the inspect panel. Done at the wrapper level so pointer-capture
+ // never steals the tap from a node.
+ if (d && !d.moved) {
+ const wrap = wrapRef.current;
+ if (!wrap) return;
+ const rect = wrap.getBoundingClientRect();
+ const sx = e.clientX - rect.left;
+ const sy = e.clientY - rect.top;
+ // screen → tree coords
+ const tx = (sx - view.x) / view.k;
+ const ty = (sy - view.y) / view.k;
+ let hit: string | null = null;
+ let best = Infinity;
+ for (const p of positions) {
+ const dist = Math.hypot(p.x - tx, p.y - ty);
+ const r = (p.node.isRoot ? 11 : 8) + 8; // generous tap radius
+ if (dist < r && dist < best) { best = dist; hit = p.id; }
+ }
+ if (hit) setSelected((s) => (s === hit ? null : hit));
+ }
+ };
+
+ // --- zoom (wheel, anchored to cursor) ---
+ const onWheel = (e: React.WheelEvent) => {
+ e.preventDefault();
+ const wrap = wrapRef.current;
+ if (!wrap) return;
+ const rect = wrap.getBoundingClientRect();
+ const cx = e.clientX - rect.left;
+ const cy = e.clientY - rect.top;
+ setView((v) => {
+ const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
+ const k = Math.min(2.4, Math.max(0.25, v.k * factor));
+ const ratio = k / v.k;
+ return { k, x: cx - (cx - v.x) * ratio, y: cy - (cy - v.y) * ratio };
+ });
+ };
+
+ // --- pinch zoom (touch) ---
+ const touchDist = (t: React.TouchList) =>
+ Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
+ const onTouchStart = (e: React.TouchEvent) => {
+ if (e.touches.length === 2) pinchRef.current = { d: touchDist(e.touches), k: view.k };
+ };
+ const onTouchMove = (e: React.TouchEvent) => {
+ if (e.touches.length === 2 && pinchRef.current) {
+ const nd = touchDist(e.touches);
+ const k = Math.min(2.4, Math.max(0.25, pinchRef.current.k * (nd / pinchRef.current.d)));
+ setView((v) => ({ ...v, k }));
+ }
+ };
+ const onTouchEnd = (e: React.TouchEvent) => {
+ if (e.touches.length < 2) pinchRef.current = null;
+ };
+
+ const zoomBy = (factor: number) => {
+ const wrap = wrapRef.current;
+ if (!wrap) return;
+ const cx = wrap.clientWidth / 2;
+ const cy = wrap.clientHeight / 2;
+ setView((v) => {
+ const k = Math.min(2.4, Math.max(0.25, v.k * factor));
+ const ratio = k / v.k;
+ return { k, x: cx - (cx - v.x) * ratio, y: cy - (cy - v.y) * ratio };
+ });
+ };
+
+ const onAttach = useCallback((n: UINode) => {
+ const params = new URLSearchParams({
+ target: n.host || "ct215",
+ session: n.session,
+ agent: /codex/i.test(n.model) ? "codex" : /glm|opencode/i.test(n.model) ? "opencode" : "claude",
+ });
+ window.location.href = `/workspace?${params.toString()}`;
+ }, []);
+
+ const selectedNode = selected ? agents.find((a) => a.id === selected) ?? null : null;
+
+ return (
+ <div className="flex flex-col gap-4 h-full min-h-0">
+ {/* header */}
+ <div>
+ <div className="flex items-center justify-between mb-3">
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest">
+ Agent nervous system
+ </p>
+ <span
+ className={`font-mono text-label-sm px-1.5 py-0.5 rounded-sm ${
+ mode === "live"
+ ? "text-teal-bio bg-teal-bio/10"
+ : mode === "connecting"
+ ? "text-yellow-400 bg-yellow-400/10"
+ : "text-text-muted bg-surface-2"
+ }`}
+ >
+ {mode === "live" ? "live" : mode === "connecting" ? "connecting…" : "mock"}
+ </span>
+ </div>
+ <div className="flex items-center gap-4 flex-wrap">
+ <span className="flex items-center gap-2">
+ <span className="w-2 h-2 rounded-full bg-teal-bio inline-block animate-pulse-slow" />
+ <span className="font-mono text-label-sm text-teal-bio">{counts.active} active</span>
+ </span>
+ {counts.idle > 0 && (
+ <span className="font-mono text-label-sm text-teal-dim">{counts.idle} idle</span>
+ )}
+ <span className="font-mono text-label-sm text-text-muted">{counts.done} done</span>
+ {counts.error > 0 && (
+ <span className="font-mono text-label-sm text-red-400">{counts.error} error</span>
+ )}
+ {totalTokens > 0 && (
+ <span className="font-mono text-label-sm text-text-muted ml-auto">
+ {(totalTokens / 1_000_000).toFixed(1)}M tok
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* tree viewport - pannable / zoomable */}
+ <div
+ ref={wrapRef}
+ className="relative flex-1 min-h-[320px] overflow-hidden rounded-sm select-none"
+ style={{
+ border: "1px solid rgba(22,224,200,0.1)",
+ background: "rgba(5,6,7,0.6)",
+ cursor: dragRef.current ? "grabbing" : "grab",
+ touchAction: "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onWheel={onWheel}
+ onTouchStart={onTouchStart}
+ onTouchMove={onTouchMove}
+ onTouchEnd={onTouchEnd}
+ aria-label="Agent tree - drag to pan, scroll or pinch to zoom, tap a node to inspect"
+ role="application"
+ >
+ <svg
+ width="100%"
+ height="100%"
+ aria-hidden="false"
+ role="img"
+ style={{ display: "block" }}
+ >
+ <g transform={`translate(${view.x},${view.y}) scale(${view.k})`}>
+ {/* edges */}
+ {agents.map((n) => {
+ if (!n.parentId) return null;
+ const from = posMap.get(n.parentId);
+ const to = posMap.get(n.id);
+ if (!from || !to) return null;
+ const live = n.status === "active";
+ // elbow connector for clean horizontal layout
+ const midX = (from.x + to.x) / 2;
+ return (
+ <path
+ key={`e-${n.id}`}
+ d={`M${from.x},${from.y} C${midX},${from.y} ${midX},${to.y} ${to.x},${to.y}`}
+ fill="none"
+ stroke={live ? "#16e0c8" : "#1e242b"}
+ strokeWidth={live ? 1.4 : 1}
+ opacity={live ? 0.5 : 0.5}
+ />
+ );
+ })}
+
+ {/* nodes */}
+ {positions.map(({ id, x, y, node }) => {
+ const color = STATUS_COLOR[node.status];
+ const r = node.isRoot ? 11 : node.status === "active" ? 8 : 6;
+ const isSel = selected === id;
+ const isHov = hovered === id;
+ const labelText = isHov ? truncate(node.label, 40) : node.short;
+ return (
+ <g
+ key={id}
+ transform={`translate(${x},${y})`}
+ style={{ cursor: "pointer" }}
+ onMouseEnter={() => setHovered(id)}
+ onMouseLeave={() => setHovered((h) => (h === id ? null : h))}
+ role="button"
+ aria-label={`${node.label} - ${node.status}`}
+ >
+ {/* selection / active ring */}
+ {(isSel || node.status === "active") && (
+ <circle
+ r={r + 5}
+ fill="none"
+ stroke={isSel ? "#16e0c8" : color}
+ strokeWidth={isSel ? 1.4 : 0.8}
+ opacity={isSel ? 0.9 : 0.35}
+ className={node.status === "active" && !isSel ? "animate-pulse-slow" : ""}
+ />
+ )}
+ <circle r={r} fill={color} fillOpacity={node.status === "done" ? 0.55 : 0.95} />
+ {node.taskCount > 0 && r >= 8 && (
+ <text textAnchor="middle" dominantBaseline="central" fontSize={8} fill="#050607" fontFamily="monospace" fontWeight={700}>
+ {node.taskCount > 99 ? "99" : node.taskCount}
+ </text>
+ )}
+ {/* label to the right of the node, vertically centered */}
+ <text
+ x={r + 8}
+ y={-1}
+ dominantBaseline="middle"
+ fontSize={node.isRoot ? 12 : 11}
+ fill={node.status === "active" ? "#e8eaed" : node.status === "done" ? "#5a626e" : "#9aa3af"}
+ fontFamily={node.isRoot ? "'Space Grotesk', sans-serif" : "'JetBrains Mono', monospace"}
+ fontWeight={node.isRoot ? 700 : 400}
+ style={{ pointerEvents: "none" }}
+ >
+ {labelText}
+ </text>
+ <text
+ x={r + 8}
+ y={12}
+ dominantBaseline="middle"
+ fontSize={8}
+ fill={node.status === "active" ? "#16e0c8" : "#3a4048"}
+ fontFamily="'JetBrains Mono', monospace"
+ style={{ pointerEvents: "none" }}
+ >
+ {node.host} · {node.lastActivity}
+ </text>
+ </g>
+ );
+ })}
+ </g>
+ </svg>
+
+ {/* zoom / fit controls */}
+ <div className="absolute top-2 right-2 flex flex-col gap-1.5">
+ <TreeBtn label="Zoom in" onClick={() => zoomBy(1.2)}>+</TreeBtn>
+ <TreeBtn label="Zoom out" onClick={() => zoomBy(1 / 1.2)}>−</TreeBtn>
+ <TreeBtn label="Fit to view" onClick={fitToView}>
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
+ <path d="M1 4V1h3M11 4V1H8M1 8v3h3M11 8v3H8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="square" />
+ </svg>
+ </TreeBtn>
+ </div>
+
+ {/* hint */}
+ <div className="absolute bottom-2 left-2 font-mono pointer-events-none" style={{ fontSize: "0.5rem", letterSpacing: "0.06em", color: "#3a4048" }}>
+ DRAG · SCROLL/PINCH · TAP NODE
+ </div>
+
+ {/* inspect panel */}
+ {selectedNode && (
+ <InspectPanel node={selectedNode} onClose={() => setSelected(null)} onAttach={onAttach} />
+ )}
+ </div>
+
+ {/* legend */}
+ <div className="flex gap-3.5 flex-wrap pt-1 border-t border-surface-2">
+ {(["active", "idle", "done", "error"] as UIStatus[]).map((s) => (
+ <span key={s} className={`flex items-center gap-1.5 font-mono text-label-sm ${STATUS_TEXT[s]}`}>
+ <span className="w-2 h-2 rounded-full inline-block" style={{ background: STATUS_COLOR[s] }} />
+ {s}
+ </span>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function TreeBtn({ children, onClick, label }: { children: React.ReactNode; onClick: () => void; label: string }) {
+ return (
+ <button
+ onClick={onClick}
+ aria-label={label}
+ title={label}
+ className="grid place-items-center font-mono text-teal-dim hover:text-teal-bio transition-colors"
+ style={{
+ width: 30,
+ height: 30,
+ background: "rgba(8,10,12,0.92)",
+ border: "1px solid rgba(22,224,200,0.16)",
+ fontSize: "1rem",
+ lineHeight: 1,
+ touchAction: "manipulation",
+ }}
+ >
+ {children}
+ </button>
+ );
+}
face/src/components/surface/AskBox.tsx +296 -0
@@ -0,0 +1,296 @@
+"use client";
+
+import { useState, useRef, useEffect, useCallback } from "react";
+import type { AskResponse, Receipt } from "@/lib/types";
+import { renderMarkdownLite } from "@/lib/markdown-lite";
+
+// Flatten raw markdown in a receipt snippet to a single clean line.
+function stripReceiptMd(s: string): string {
+ return s
+ .replace(/```[\s\S]*?```/g, " ")
+ .replace(/`([^`]*)`/g, "$1")
+ .replace(/^#{1,6}\s*/gm, "")
+ .replace(/^\s*[-*+]\s+/gm, "· ")
+ .replace(/\*\*(.*?)\*\*/g, "$1")
+ .replace(/\*(.*?)\*/g, "$1")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+interface AskBoxProps {
+ data: AskResponse | null;
+ loading: boolean;
+ error: string | null;
+ onSubmit: (query: string) => Promise<void>;
+ onFocusChange?: (focused: boolean) => void;
+}
+
+function ReceiptChip({ receipt, isExpanded, onToggle }: {
+ receipt: Receipt;
+ isExpanded: boolean;
+ onToggle: () => void;
+}) {
+ const confidencePct = Math.round(receipt.confidence * 100);
+
+ return (
+ <button
+ onClick={onToggle}
+ data-cursor-expand
+ className="group text-left w-full"
+ aria-expanded={isExpanded}
+ >
+ <div className="border-l-2 border-teal-bio pl-3 py-1.5 bg-surface-1 hover:bg-surface-2 transition-colors duration-200 rounded-r-sm">
+ <div className="flex items-center gap-2 flex-wrap">
+ <span className="font-mono text-label-sm text-teal-bio uppercase tracking-widest">
+ [{receipt.source}]
+ </span>
+ <span className="font-mono text-label-sm text-text-muted truncate max-w-[200px]">
+ {receipt.path}
+ </span>
+ <span className="font-mono text-label-sm text-text-secondary ml-auto flex-shrink-0">
+ {confidencePct}%
+ </span>
+ </div>
+
+ {isExpanded && (
+ <div className="mt-2 pt-2 border-t border-surface-3">
+ <p className="font-ui text-xs text-text-secondary leading-relaxed">
+ {stripReceiptMd(receipt.snippet)}
+ </p>
+ <p className="font-mono text-label-sm text-text-muted mt-1">
+ {new Date(receipt.last_seen).toLocaleString()}
+ </p>
+ </div>
+ )}
+ </div>
+ </button>
+ );
+}
+
+export default function AskBox({ data, loading, error, onSubmit, onFocusChange }: AskBoxProps) {
+ const [query, setQuery] = useState("");
+ const [expandedReceipts, setExpandedReceipts] = useState<Set<string>>(new Set());
+ const inputRef = useRef<HTMLInputElement>(null);
+ const answerRef = useRef<HTMLDivElement>(null);
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!query.trim() || loading) return;
+ await onSubmit(query);
+ },
+ [query, loading, onSubmit]
+ );
+
+ const toggleReceipt = useCallback((id: string) => {
+ setExpandedReceipts((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ // Animate answer in when data arrives - double-rAF for smooth paint
+ useEffect(() => {
+ if (!data || !answerRef.current) return;
+ const el = answerRef.current;
+ el.style.opacity = "0";
+ el.style.transform = "translateY(20px)";
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ el.style.transition = "opacity 0.7s cubic-bezier(0.16,1,0.3,1), transform 0.7s cubic-bezier(0.16,1,0.3,1)";
+ el.style.opacity = "1";
+ el.style.transform = "translateY(0)";
+ });
+ });
+ }, [data]);
+
+ return (
+ <div className="flex flex-col gap-7 h-full">
+ {/* Header */}
+ <div>
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3">
+ Ask Darwin
+ </p>
+ <h2 className="font-display text-display-md font-bold text-text-primary leading-tight">
+ Query the organism
+ </h2>
+ </div>
+
+ {/* Input - generous vertical padding for comfortable touch target */}
+ <form onSubmit={handleSubmit} className="relative">
+ <label htmlFor="darwin-ask" className="sr-only">
+ Ask Darwin a question about your homelab
+ </label>
+ <input
+ ref={inputRef}
+ id="darwin-ask"
+ type="text"
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ placeholder="What is Hermes doing right now?"
+ autoComplete="off"
+ autoCorrect="off"
+ autoCapitalize="off"
+ spellCheck="false"
+ disabled={loading}
+ onFocus={() => onFocusChange?.(true)}
+ onBlur={() => onFocusChange?.(false)}
+ style={{ fontSize: "16px", minHeight: "44px" }}
+ className={[
+ "w-full bg-transparent border-b-2 border-text-muted",
+ "focus:border-teal-bio outline-none",
+ "font-ui text-text-primary placeholder:text-text-muted",
+ "py-3 pr-16 transition-colors duration-300",
+ "disabled:opacity-50",
+ ].join(" ")}
+ />
+ <button
+ type="submit"
+ disabled={loading || !query.trim()}
+ aria-label="Submit query"
+ style={{ minWidth: "44px", minHeight: "44px", touchAction: "manipulation" }}
+ className={[
+ "absolute right-0 top-1/2 -translate-y-1/2",
+ "w-11 h-11 flex items-center justify-center",
+ "border border-text-muted hover:border-teal-bio",
+ "text-text-muted hover:text-teal-bio",
+ "transition-all duration-200 disabled:opacity-30",
+ ].join(" ")}
+ data-cursor-expand
+ >
+ {loading ? (
+ <svg className="animate-spin" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+ <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" strokeDasharray="20 17" />
+ </svg>
+ ) : (
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+ <path d="M2 8h12M8 2l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" />
+ </svg>
+ )}
+ </button>
+ </form>
+
+ {/* Loading state */}
+ {loading && (
+ <div className="flex items-center gap-3">
+ <div className="flex gap-1" aria-hidden="true">
+ {[0, 1, 2].map((i) => (
+ <div
+ key={i}
+ className="w-1.5 h-1.5 rounded-full bg-teal-bio"
+ style={{
+ animation: `pulse 1.2s ease-in-out ${i * 0.2}s infinite`,
+ }}
+ />
+ ))}
+ </div>
+ <span className="font-mono text-label-sm text-text-muted" aria-live="polite">
+ Querying all engines...
+ </span>
+ </div>
+ )}
+
+ {/* Error */}
+ {error && (
+ <div className="border-l-2 border-red-500 pl-3 py-2" role="alert">
+ <p className="font-mono text-label-sm text-red-400">{error}</p>
+ </div>
+ )}
+
+ {/* Answer + receipts */}
+ {data && (
+ <div ref={answerRef} className="flex flex-col gap-6 overflow-y-auto w-full max-w-full">
+ {/* Answer prose - editorial line-height; render markdown-lite */}
+ {data.answer && (
+ <div className="w-full max-w-full">
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-4">
+ Answer
+ </p>
+ <div
+ className="font-ui text-base text-text-primary answer-prose"
+ style={{ lineHeight: "1.75", letterSpacing: "0.005em" }}
+ dangerouslySetInnerHTML={{ __html: renderMarkdownLite(data.answer) }}
+ />
+ </div>
+ )}
+
+ {/* Engine meta - breathable badge row */}
+ <div className="flex items-center gap-2 flex-wrap pt-2 border-t border-surface-2">
+ {data.engines.map((eng) => (
+ <span
+ key={eng.engine}
+ className={[
+ "font-mono text-label-sm px-3 py-1 border rounded-sm",
+ eng.status === "up"
+ ? "border-teal-muted text-teal-bio"
+ : "border-surface-3 text-text-muted",
+ ].join(" ")}
+ >
+ {eng.engine}
+ {eng.ms != null && <span className="opacity-50 ml-1.5">{eng.ms}ms</span>}
+ </span>
+ ))}
+ <span className="font-mono text-label-sm text-text-muted ml-auto opacity-60">
+ {data.took_ms}ms total
+ </span>
+ </div>
+
+ {/* Receipt chips - more vertical breathing room */}
+ {data.receipts.length > 0 && (
+ <div>
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3">
+ Sources - {data.receipts.length}
+ </p>
+ <div className="flex flex-col gap-2.5">
+ {data.receipts.map((receipt) => (
+ <ReceiptChip
+ key={receipt.id}
+ receipt={receipt}
+ isExpanded={expandedReceipts.has(receipt.id)}
+ onToggle={() => toggleReceipt(receipt.id)}
+ />
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Conflicts */}
+ {data.conflicts.length > 0 && (
+ <div>
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
+ Conflicts
+ </p>
+ {data.conflicts.map((c, i) => (
+ <div key={i} className="border-l-2 border-yellow-600 pl-3 py-1.5 mb-2">
+ <p className="font-mono text-label-sm text-yellow-500">{c.claim}</p>
+ <p className="font-mono text-label-sm text-text-muted">
+ Winner: {c.winner} - {c.reason}
+ </p>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Empty state */}
+ {!data && !loading && !error && (
+ <div className="flex-1 flex items-center justify-center">
+ <div className="text-center">
+ <div className="w-12 h-12 mx-auto mb-4 border border-surface-3 flex items-center justify-center" aria-hidden="true">
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
+ <circle cx="10" cy="10" r="8" stroke="#16e0c8" strokeWidth="1" opacity="0.4" />
+ <circle cx="10" cy="10" r="3" fill="#16e0c8" opacity="0.6" />
+ </svg>
+ </div>
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest">
+ Organism ready
+ </p>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
face/src/components/surface/HitsInbox.tsx +183 -0
@@ -0,0 +1,183 @@
+"use client";
+
+import type { Hit } from "@/lib/types";
+
+interface HitsInboxProps {
+ hits: Hit[];
+}
+
+// Friendly source labels for the engine badge.
+const ENGINE_LABEL: Record<string, string> = {
+ rag: "RAG",
+ "homelab-rag": "RAG",
+ cbm: "CBM",
+ "cbm-homelab": "CBM",
+ serena: "SERENA",
+ memgraph: "MEMORY",
+ "memory-graph": "MEMORY",
+ inbox: "INBOX",
+ "proxmox-api": "PROXMOX",
+ proxmox: "PROXMOX",
+};
+
+function engineLabel(engine: string): string {
+ return ENGINE_LABEL[engine] ?? engine.toUpperCase();
+}
+
+// Strip raw markdown so snippets/titles read as clean prose in a card.
+function stripMarkdown(s: string): string {
+ return s
+ .replace(/```[\s\S]*?```/g, " ") // fenced code
+ .replace(/`([^`]*)`/g, "$1") // inline code
+ .replace(/^#{1,6}\s*/gm, "") // headings
+ .replace(/^\s*[-*+]\s+/gm, "· ") // list bullets → middot
+ .replace(/\*\*(.*?)\*\*/g, "$1") // bold
+ .replace(/\*(.*?)\*/g, "$1") // italics
+ .replace(/_/g, "") // stray underscores
+ .replace(/>\s?/g, "") // blockquote markers
+ .replace(/\[(.*?)\]\(.*?\)/g, "$1") // links → text
+ .replace(/\s+/g, " ") // collapse whitespace
+ .trim();
+}
+
+// Title is often a markdown fragment ("9. Fun / novel"); normalize it.
+function cleanTitle(t: string): string {
+ return stripMarkdown(t).replace(/^\d+\.\s*/, "");
+}
+
+// Shorten an absolute path to its tail for scannability, keep full in title attr.
+function shortPath(p: string): string {
+ if (!p) return "";
+ if (p.startsWith("memory://") || p.includes("://")) {
+ return p.replace(/^[a-z]+:\/\//, "").slice(0, 48);
+ }
+ const parts = p.split("/").filter(Boolean);
+ if (parts.length <= 2) return p;
+ return "…/" + parts.slice(-2).join("/");
+}
+
+function scoreTone(score: number): { label: string; color: string } {
+ if (score >= 0.45) return { label: "strong", color: "#16e0c8" };
+ if (score >= 0.3) return { label: "fair", color: "#0d9e8e" };
+ return { label: "weak", color: "#4a525e" };
+}
+
+function HitCard({ hit, index, max }: { hit: Hit; index: number; max: number }) {
+ const title = cleanTitle(hit.title) || shortPath(hit.path);
+ const snippet = stripMarkdown(hit.snippet);
+ const tone = scoreTone(hit.score);
+ // Bar is relative to the top score so the real spread is visible.
+ const barPct = max > 0 ? Math.max(8, Math.round((hit.score / max) * 100)) : 0;
+
+ return (
+ <article
+ className="group py-3.5 px-3 -mx-1 border-b border-surface-2 last:border-0 hover:bg-surface-1/60 transition-colors duration-150"
+ style={{ animationDelay: `${index * 50}ms` }}
+ >
+ {/* top row: source badge + score */}
+ <div className="flex items-center justify-between gap-3 mb-2">
+ <span
+ className="font-mono uppercase shrink-0"
+ style={{
+ fontSize: "0.56rem",
+ letterSpacing: "0.1em",
+ color: "#16e0c8",
+ border: "1px solid rgba(22,224,200,0.22)",
+ padding: "2px 6px",
+ borderRadius: 2,
+ }}
+ >
+ {engineLabel(hit.engine)}
+ </span>
+ <div className="flex items-center gap-2 shrink-0">
+ <div className="w-14 h-1 bg-surface-3 relative rounded-full overflow-hidden">
+ <div
+ className="absolute left-0 top-0 h-full rounded-full"
+ style={{ width: `${barPct}%`, background: tone.color, transition: "width 0.5s cubic-bezier(0.16,1,0.3,1)" }}
+ />
+ </div>
+ <span className="font-mono tabular-nums" style={{ fontSize: "0.62rem", color: tone.color }} title={`relevance score ${tone.label}`}>
+ {hit.score.toFixed(2)}
+ </span>
+ </div>
+ </div>
+
+ {/* title */}
+ <p className="font-ui text-sm text-text-primary font-medium leading-snug mb-1 line-clamp-2">
+ {title}
+ </p>
+
+ {/* path */}
+ <p className="font-mono truncate mb-1.5" style={{ fontSize: "0.56rem", color: "#4a525e", letterSpacing: "0.02em" }} title={hit.path}>
+ {shortPath(hit.path)}
+ </p>
+
+ {/* snippet */}
+ <p className="font-ui text-text-secondary leading-relaxed line-clamp-2" style={{ fontSize: "0.8rem" }}>
+ {snippet}
+ </p>
+ </article>
+ );
+}
+
+function EmptyInbox() {
+ return (
+ <div className="flex-1 flex items-center justify-center py-12">
+ <div className="text-center">
+ <div className="w-10 h-10 mx-auto mb-3 border border-surface-2 flex items-center justify-center">
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+ <rect x="2" y="4" width="12" height="9" stroke="#3a3a3a" strokeWidth="1" />
+ <path d="M2 7h12" stroke="#3a3a3a" strokeWidth="1" />
+ <path d="M5 4V3M11 4V3" stroke="#3a3a3a" strokeWidth="1" />
+ </svg>
+ </div>
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest">No hits yet</p>
+ <p className="font-mono text-label-sm text-text-muted mt-1 opacity-50">Ask Darwin a question</p>
+ </div>
+ </div>
+ );
+}
+
+export default function HitsInbox({ hits }: HitsInboxProps) {
+ const sorted = [...hits].sort((a, b) => b.score - a.score);
+ const max = sorted.length ? sorted[0].score : 0;
+
+ return (
+ <div className="flex flex-col gap-4 h-full">
+ <div className="flex items-center justify-between">
+ <div>
+ <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-1">Raw hits</p>
+ {sorted.length > 0 && (
+ <p className="font-mono text-label-sm text-text-secondary">
+ {sorted.length} results · top {max.toFixed(2)}
+ </p>
+ )}
+ </div>
+ {sorted.length > 0 && (
+ <div className="flex items-center gap-1.5">
+ <span className="w-1.5 h-1.5 rounded-full bg-teal-bio animate-pulse-slow" />
+ <span className="font-mono text-label-sm text-teal-bio">LIVE</span>
+ </div>
+ )}
+ </div>
+
+ <div className="flex-1 overflow-y-auto">
+ {sorted.length === 0 ? (
+ <EmptyInbox />
+ ) : (
+ <div>
+ {sorted.map((hit, i) => (
+ <HitCard key={`${hit.engine}-${i}`} hit={hit} index={i} max={max} />
+ ))}
+ </div>
+ )}
+ </div>
+
+ {sorted.length > 0 && (
+ <div className="pt-2 border-t border-surface-2">
+ <p className="font-mono text-label-sm text-text-muted">Sorted by relevance score</p>
+ </div>
+ )}
+ </div>
+ );
+}
face/src/components/workspace/Launcher.tsx +403 -0
@@ -0,0 +1,403 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+
+// A session is "live" if attached OR recently created; older detached tmux
+// sessions are treated as idle leftovers (dim + grouped + easy kill).
+const LIVE_WINDOW_S = 20 * 60;
+
+function isLive(s: LiveSession): boolean {
+ if (s.attached) return true;
+ const t = parseInt(s.created, 10);
+ if (!t) return false;
+ return Date.now() / 1000 - t < LIVE_WINDOW_S;
+}
+import type {
+ TargetsResponse,
+ TermCreds,
+ LiveSession,
+ AgentKind,
+ TermMode,
+ LaunchSpec,
+} from "@/lib/term";
+import { fetchSessions, AGENT_LABEL } from "@/lib/term";
+
+interface LauncherProps {
+ creds: TermCreds | null;
+ targets: TargetsResponse | null;
+ onLaunch: (spec: LaunchSpec) => void;
+ onKillSession: (s: LiveSession) => void;
+ collapsed: boolean;
+}
+
+// ── shared field chrome (brutalist mono, no rounded shadow cards) ──
+function FieldLabel({ children }: { children: React.ReactNode }) {
+ return (
+ <span
+ className="font-mono uppercase block"
+ style={{ fontSize: "0.58rem", letterSpacing: "0.14em", color: "#4a525e", marginBottom: 7 }}
+ >
+ {children}
+ </span>
+ );
+}
+
+// relative age from a unix-seconds string
+function ageOf(created: string): string {
+ const t = parseInt(created, 10);
+ if (!t) return "";
+ const s = Math.max(0, Math.floor(Date.now() / 1000 - t));
+ if (s < 60) return `${s}s`;
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
+ return `${Math.floor(s / 86400)}d`;
+}
+
+const selectStyle: React.CSSProperties = {
+ width: "100%",
+ height: 40,
+ background: "#0a0c0e",
+ border: "1px solid rgba(22,224,200,0.14)",
+ color: "#e8eaed",
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: "0.74rem",
+ letterSpacing: "0.02em",
+ padding: "0 12px",
+ outline: "none",
+ appearance: "none",
+ borderRadius: 0,
+ cursor: "pointer",
+};
+
+// One session row - live ones are full-contrast + glowing; idle ones dimmed.
+function SessionRow({
+ s,
+ live,
+ onAttach,
+ onKill,
+}: {
+ s: LiveSession;
+ live: boolean;
+ onAttach: (s: LiveSession) => void;
+ onKill: (s: LiveSession) => void;
+}) {
+ const accent = live ? "#16e0c8" : "#4a525e";
+ return (
+ <div
+ className="group"
+ style={{
+ background: "#0a0c0e",
+ border: `1px solid ${live ? "rgba(22,224,200,0.18)" : "rgba(22,224,200,0.06)"}`,
+ opacity: live ? 1 : 0.62,
+ }}
+ >
+ <button
+ onClick={() => onAttach(s)}
+ className="flex w-full items-center justify-between gap-2 px-3.5 text-left"
+ style={{ minHeight: live ? 52 : 42, transition: "background 0.2s" }}
+ onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(22,224,200,0.06)")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ >
+ <div className="min-w-0">
+ <div className="flex items-center gap-2">
+ {live && (
+ <span className="rounded-full shrink-0" style={{ width: 5, height: 5, background: "#16e0c8", boxShadow: "0 0 6px #16e0c8" }} />
+ )}
+ <span className="font-display font-bold uppercase" style={{ fontSize: live ? "0.74rem" : "0.66rem", color: accent, letterSpacing: "0.03em" }}>
+ {AGENT_LABEL[(s.agent as AgentKind)] ?? s.agent}
+ </span>
+ <span className="font-mono" style={{ fontSize: "0.56rem", color: "#8b929e", letterSpacing: "0.04em" }}>
+ {s.target}
+ </span>
+ {s.attached && (
+ <span className="font-mono" style={{ fontSize: "0.5rem", color: "#0d9e8e", letterSpacing: "0.06em", border: "1px solid rgba(22,224,200,0.24)", padding: "1px 4px" }}>
+ OPEN
+ </span>
+ )}
+ </div>
+ <span className="font-mono block truncate" style={{ fontSize: "0.54rem", color: "#4a525e", maxWidth: 180, marginTop: 3 }} title={s.session}>
+ {s.session} · {ageOf(s.created)} ago
+ </span>
+ </div>
+ <span className="font-display font-bold uppercase shrink-0 flex items-center gap-1" style={{ fontSize: "0.6rem", color: accent, letterSpacing: "0.06em" }}>
+ Attach ▸
+ </span>
+ </button>
+ <button
+ onClick={() => onKill(s)}
+ className="font-mono uppercase w-full opacity-40 hover:opacity-100"
+ style={{ fontSize: "0.5rem", letterSpacing: "0.08em", color: "#ff6b6b", padding: "5px", borderTop: "1px solid rgba(255,107,107,0.12)", transition: "opacity 0.2s", minHeight: 30 }}
+ >
+ Kill session
+ </button>
+ </div>
+ );
+}
+
+export default function Launcher({
+ creds,
+ targets,
+ onLaunch,
+ onKillSession,
+ collapsed,
+}: LauncherProps) {
+ const [targetKey, setTargetKey] = useState<string>("");
+ const [agent, setAgent] = useState<AgentKind>("shell");
+ const [mode, setMode] = useState<TermMode>("persistent");
+ const [cwd, setCwd] = useState<string>("");
+ const [sessions, setSessions] = useState<LiveSession[]>([]);
+ const [showIdle, setShowIdle] = useState(false);
+
+ const { liveSessions, idleSessions } = useMemo(() => {
+ const live: LiveSession[] = [];
+ const idle: LiveSession[] = [];
+ for (const s of sessions) (isLive(s) ? live : idle).push(s);
+ // newest first within each group
+ const byNew = (a: LiveSession, b: LiveSession) =>
+ parseInt(b.created, 10) - parseInt(a.created, 10);
+ return { liveSessions: live.sort(byNew), idleSessions: idle.sort(byNew) };
+ }, [sessions]);
+
+ const target = useMemo(
+ () => targets?.targets.find((t) => t.key === targetKey) ?? null,
+ [targets, targetKey]
+ );
+
+ // seed defaults once targets arrive
+ useEffect(() => {
+ if (targets && !targetKey && targets.targets.length) {
+ const first = targets.targets[0];
+ setTargetKey(first.key);
+ setAgent(first.default_agent);
+ setCwd(first.default_cwd ?? "");
+ }
+ }, [targets, targetKey]);
+
+ // when target changes, reset agent/cwd to its defaults
+ useEffect(() => {
+ if (target) {
+ setAgent((a) => (target.agents.includes(a) ? a : target.default_agent));
+ setCwd(target.default_cwd ?? "");
+ }
+ }, [target]);
+
+ // poll live sessions
+ useEffect(() => {
+ if (!creds) return;
+ let alive = true;
+ const tick = async () => {
+ try {
+ const s = await fetchSessions(creds);
+ if (alive) setSessions(s);
+ } catch {
+ /* ignore */
+ }
+ };
+ tick();
+ const id = setInterval(tick, 5000);
+ return () => {
+ alive = false;
+ clearInterval(id);
+ };
+ }, [creds]);
+
+ if (collapsed) return null;
+
+ const launch = () => {
+ if (!targetKey) return;
+ onLaunch({ target: targetKey, agent, mode, cwd });
+ };
+
+ const attach = (s: LiveSession) => {
+ onLaunch({
+ target: s.target,
+ agent: (s.agent as AgentKind) || "shell",
+ mode: "persistent",
+ cwd: "",
+ session: s.session,
+ });
+ };
+
+ return (
+ <div
+ className="flex h-full flex-col overflow-y-auto"
+ style={{ background: "rgba(8,10,12,0.96)", borderRight: "1px solid rgba(22,224,200,0.10)" }}
+ >
+ {/* header */}
+ <div className="px-4 pt-4 pb-3" style={{ borderBottom: "1px solid rgba(22,224,200,0.08)" }}>
+ <p
+ className="font-mono uppercase"
+ style={{ fontSize: "0.55rem", letterSpacing: "0.2em", color: "#0d9e8e", marginBottom: 6 }}
+ >
+ Team-Up
+ </p>
+ <h2 className="font-display font-bold uppercase" style={{ fontSize: "1.25rem", lineHeight: 0.92, color: "#e8eaed", letterSpacing: "-0.02em" }}>
+ Workspace
+ </h2>
+ </div>
+
+ {/* ── SESSIONS - split live vs idle. ~33 detached tmux from past Cockpit
+ use is noise; the genuinely-active ones are highlighted up top, the
+ idle leftovers collapse under a group with one-tap kill. ── */}
+ <div className="px-4 pt-4 pb-3" style={{ borderBottom: "1px solid rgba(22,224,200,0.08)" }}>
+ <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
+ <span className="font-display font-bold uppercase" style={{ fontSize: "0.92rem", color: "#16e0c8", letterSpacing: "0.02em" }}>
+ Sessions
+ </span>
+ <span className="flex items-center gap-1.5">
+ <span className="rounded-full" style={{ width: 6, height: 6, background: liveSessions.length ? "#16e0c8" : "#4a525e", boxShadow: liveSessions.length ? "0 0 8px #16e0c8" : "none", animation: liveSessions.length ? "glow-breathe 2.4s ease-in-out infinite" : "none" }} />
+ <span className="font-mono" style={{ fontSize: "0.56rem", color: "#0d9e8e", letterSpacing: "0.08em" }}>
+ {liveSessions.length} LIVE · {idleSessions.length} IDLE
+ </span>
+ </span>
+ </div>
+
+ {sessions.length === 0 && (
+ <p className="font-mono" style={{ fontSize: "0.62rem", color: "#4a525e", letterSpacing: "0.03em", lineHeight: 1.6 }}>
+ No running sessions. Launch one below - persistent sessions survive
+ reload and reattach from any device (phone or PC).
+ </p>
+ )}
+
+ {/* LIVE - highlighted */}
+ {liveSessions.length > 0 && (
+ <div className="space-y-2.5 mb-3">
+ {liveSessions.map((s) => (
+ <SessionRow key={s.session} s={s} live onAttach={attach} onKill={onKillSession} />
+ ))}
+ </div>
+ )}
+
+ {/* IDLE - collapsible group */}
+ {idleSessions.length > 0 && (
+ <div>
+ <button
+ onClick={() => setShowIdle((v) => !v)}
+ className="font-mono uppercase flex items-center justify-between w-full"
+ style={{ fontSize: "0.54rem", letterSpacing: "0.12em", color: "#4a525e", padding: "6px 0" }}
+ >
+ <span>{showIdle ? "▾" : "▸"} {idleSessions.length} idle leftover{idleSessions.length === 1 ? "" : "s"}</span>
+ <span
+ role="button"
+ tabIndex={0}
+ onClick={(e) => { e.stopPropagation(); idleSessions.forEach(onKillSession); }}
+ onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); idleSessions.forEach(onKillSession); } }}
+ className="hover:opacity-100 opacity-60"
+ style={{ color: "#ff6b6b", letterSpacing: "0.08em" }}
+ >
+ Kill all ▸
+ </span>
+ </button>
+ {showIdle && (
+ <div className="space-y-1.5 mt-1.5">
+ {idleSessions.map((s) => (
+ <SessionRow key={s.session} s={s} live={false} onAttach={attach} onKill={onKillSession} />
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* launch form */}
+ <div className="px-4 pt-4 pb-4 space-y-4">
+ <p className="font-mono uppercase" style={{ fontSize: "0.55rem", letterSpacing: "0.16em", color: "#4a525e", marginBottom: 2 }}>
+ + New agent
+ </p>
+ <div>
+ <FieldLabel>Source</FieldLabel>
+ <select style={selectStyle} value={targetKey} onChange={(e) => setTargetKey(e.target.value)}>
+ {targets?.targets.map((t) => (
+ <option key={t.key} value={t.key} style={{ background: "#0a0c0e" }}>
+ {t.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ <div>
+ <FieldLabel>Agent</FieldLabel>
+ <select style={selectStyle} value={agent} onChange={(e) => setAgent(e.target.value as AgentKind)}>
+ {target?.agents.map((a) => (
+ <option key={a} value={a} style={{ background: "#0a0c0e" }}>
+ {AGENT_LABEL[a]} - {a}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ <div>
+ <FieldLabel>Mode</FieldLabel>
+ <div className="grid grid-cols-2 gap-2">
+ {(["persistent", "ephemeral"] as TermMode[]).map((m) => {
+ const on = mode === m;
+ return (
+ <button
+ key={m}
+ onClick={() => setMode(m)}
+ className="font-mono uppercase"
+ style={{
+ height: 40,
+ fontSize: "0.62rem",
+ letterSpacing: "0.08em",
+ color: on ? "#050607" : "#8b929e",
+ background: on ? "#16e0c8" : "#0a0c0e",
+ border: on ? "1px solid #16e0c8" : "1px solid rgba(22,224,200,0.14)",
+ transition: "all 0.25s cubic-bezier(0.16,1,0.3,1)",
+ }}
+ >
+ {m}
+ </button>
+ );
+ })}
+ </div>
+ <p className="font-mono" style={{ fontSize: "0.55rem", color: "#4a525e", marginTop: 6, letterSpacing: "0.02em" }}>
+ {mode === "persistent" ? "tmux - survives reload, reattachable" : "ephemeral - dies with the socket"}
+ </p>
+ </div>
+
+ <div>
+ <FieldLabel>Working dir</FieldLabel>
+ <select style={selectStyle} value={cwd} onChange={(e) => setCwd(e.target.value)}>
+ {(target?.dirs ?? targets?.common_dirs ?? []).map((d) => (
+ <option key={d.path || "home"} value={d.path} style={{ background: "#0a0c0e" }}>
+ {d.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ <button
+ onClick={launch}
+ disabled={!targetKey}
+ className="font-display font-bold uppercase w-full group"
+ style={{
+ height: 48,
+ fontSize: "0.84rem",
+ letterSpacing: "0.08em",
+ color: "#050607",
+ background: "#16e0c8",
+ border: "none",
+ cursor: targetKey ? "pointer" : "not-allowed",
+ opacity: targetKey ? 1 : 0.4,
+ transition: "filter 0.2s",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.12)")}
+ onMouseLeave={(e) => (e.currentTarget.style.filter = "brightness(1)")}
+ >
+ Launch ▸
+ </button>
+ </div>
+
+ {/* continuity + 3060 local models note */}
+ <div className="mt-auto px-4 py-3" style={{ borderTop: "1px solid rgba(22,224,200,0.08)" }}>
+ <FieldLabel>Cross-device · RTX 3060</FieldLabel>
+ <p className="font-mono" style={{ fontSize: "0.58rem", color: "#8b929e", lineHeight: 1.55, letterSpacing: "0.01em" }}>
+ Persistent (default) = tmux on the host. Attach the SAME session from your phone that is
+ open on your PC. GLM (OpenCode / CT247) runs on the 3060 when the GPU is awake; Codex
+ (CT241) and Claude (CT215) call out.
+ </p>
+ </div>
+ </div>
+ );
+}
face/src/components/workspace/SessionPicker.tsx +248 -0
@@ -0,0 +1,248 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { LiveSession, AgentKind } from "@/lib/term";
+import { AGENT_LABEL } from "@/lib/term";
+
+interface SessionPickerProps {
+ openSessionIds: Set<string>;
+ onPick: (s: LiveSession) => void;
+ onClose: () => void;
+ onLaunchNew: () => void;
+}
+
+function ageOf(created: string): string {
+ const t = parseInt(created, 10);
+ if (!t) return "";
+ const s = Math.max(0, Math.floor(Date.now() / 1000 - t));
+ if (s < 60) return `${s}s`;
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
+ return `${Math.floor(s / 86400)}d`;
+}
+
+function truncate(s: string, n: number): string {
+ return s.length > n ? `${s.slice(0, n)}…` : s;
+}
+
+export default function SessionPicker({
+ openSessionIds,
+ onPick,
+ onClose,
+ onLaunchNew,
+}: SessionPickerProps) {
+ const [sessions, setSessions] = useState<LiveSession[]>([]);
+
+ // fetch live sessions on mount + every 5s
+ useEffect(() => {
+ let alive = true;
+ const tick = async () => {
+ try {
+ const res = await fetch("/api/term/sessions", { cache: "no-store" });
+ const data = await res.json();
+ if (alive) setSessions((data.sessions ?? []) as LiveSession[]);
+ } catch {
+ /* ignore */
+ }
+ };
+ tick();
+ const id = setInterval(tick, 5000);
+ return () => {
+ alive = false;
+ clearInterval(id);
+ };
+ }, []);
+
+ // Esc to dismiss
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ // newest-first by created
+ const sorted = [...sessions].sort(
+ (a, b) => parseInt(b.created, 10) - parseInt(a.created, 10)
+ );
+
+ return (
+ <div
+ className="fixed inset-0 z-[60] flex items-center justify-center"
+ style={{ background: "rgba(5,6,7,0.6)", backdropFilter: "blur(2px)" }}
+ onClick={onClose}
+ >
+ <div
+ onClick={(e) => e.stopPropagation()}
+ className="flex flex-col"
+ style={{
+ background: "#080a0c",
+ border: "1px solid rgba(22,224,200,0.2)",
+ maxWidth: 520,
+ width: "calc(100% - 32px)",
+ maxHeight: "70vh",
+ }}
+ >
+ {/* header */}
+ <div
+ className="flex shrink-0 items-center justify-between"
+ style={{ padding: "12px 14px", borderBottom: "1px solid rgba(22,224,200,0.12)" }}
+ >
+ <div className="flex items-center gap-2 min-w-0">
+ <span
+ className="font-mono uppercase shrink-0"
+ style={{ fontSize: "0.55rem", letterSpacing: "0.2em", color: "#0d9e8e" }}
+ >
+ ⇄ Assign
+ </span>
+ <span
+ className="font-display font-bold uppercase truncate"
+ style={{ fontSize: "0.82rem", letterSpacing: "0.04em", color: "#e8eaed" }}
+ >
+ Live Sessions
+ </span>
+ </div>
+ <button
+ onClick={onClose}
+ aria-label="Close"
+ className="flex shrink-0 items-center justify-center"
+ style={{ width: 24, height: 24, color: "#4a525e", transition: "color 0.2s" }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = "#ff6b6b")}
+ onMouseLeave={(e) => (e.currentTarget.style.color = "#4a525e")}
+ >
+ <svg width="12" height="12" viewBox="0 0 11 11" fill="none">
+ <path d="M1 1L10 10M10 1L1 10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
+ </svg>
+ </button>
+ </div>
+
+ {/* list */}
+ <div className="min-h-0 flex-1 overflow-y-auto">
+ {sorted.length === 0 && (
+ <p
+ className="font-mono"
+ style={{
+ fontSize: "0.62rem",
+ color: "#4a525e",
+ letterSpacing: "0.03em",
+ padding: "24px 14px",
+ lineHeight: 1.6,
+ }}
+ >
+ No sessions running. Launch a new one below.
+ </p>
+ )}
+
+ {sorted.map((s) => {
+ const inPane = openSessionIds.has(s.session);
+ const agent = (s.agent as AgentKind) || "shell";
+ return (
+ <button
+ key={s.session}
+ onClick={() => onPick(s)}
+ className="flex w-full items-center gap-2.5 text-left"
+ style={{
+ padding: "10px 14px",
+ borderBottom: "1px solid rgba(22,224,200,0.06)",
+ transition: "background 0.15s",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(22,224,200,0.06)")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ >
+ {/* gold "open in a pane" dot */}
+ <span className="flex shrink-0 items-center justify-center" style={{ width: 6, height: 6 }}>
+ {inPane && (
+ <span
+ style={{
+ width: 6,
+ height: 6,
+ background: "#f5c518",
+ boxShadow: "0 0 6px #f5c518",
+ display: "block",
+ }}
+ />
+ )}
+ </span>
+
+ <div className="min-w-0 flex-1">
+ <div className="flex items-center gap-2">
+ <span
+ className="font-display font-bold uppercase"
+ style={{ fontSize: "0.72rem", color: "#e8eaed", letterSpacing: "0.03em" }}
+ >
+ {AGENT_LABEL[agent] ?? s.agent}
+ </span>
+ <span className="font-mono" style={{ fontSize: "0.58rem", color: "#8b929e", letterSpacing: "0.04em" }}>
+ {s.target}
+ </span>
+ {inPane && (
+ <span className="font-mono" style={{ fontSize: "0.5rem", color: "#f5c518", letterSpacing: "0.08em" }}>
+ IN PANE
+ </span>
+ )}
+ {s.attached && (
+ <span
+ className="font-mono"
+ style={{
+ fontSize: "0.5rem",
+ color: "#0d9e8e",
+ letterSpacing: "0.06em",
+ border: "1px solid rgba(22,224,200,0.24)",
+ padding: "1px 4px",
+ }}
+ >
+ OPEN
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ <span
+ className="font-mono truncate"
+ style={{ fontSize: "0.54rem", color: "#4a525e", letterSpacing: "0.04em", maxWidth: 320 }}
+ title={s.session}
+ >
+ {truncate(s.session, 26)}
+ </span>
+ <span className="font-mono shrink-0" style={{ fontSize: "0.52rem", color: "#4a525e", letterSpacing: "0.04em" }}>
+ {ageOf(s.created)} ago
+ </span>
+ </div>
+ </div>
+
+ <span
+ className="font-display font-bold uppercase shrink-0"
+ style={{ fontSize: "0.6rem", color: "#16e0c8", letterSpacing: "0.06em" }}
+ >
+ Load ▸
+ </span>
+ </button>
+ );
+ })}
+ </div>
+
+ {/* footer - keep new launches possible via the sidebar */}
+ <div className="shrink-0" style={{ padding: "10px 14px", borderTop: "1px solid rgba(22,224,200,0.12)" }}>
+ <button
+ onClick={onLaunchNew}
+ className="font-display font-bold uppercase w-full"
+ style={{
+ height: 40,
+ fontSize: "0.72rem",
+ letterSpacing: "0.08em",
+ color: "#050607",
+ background: "#16e0c8",
+ border: "none",
+ cursor: "pointer",
+ transition: "filter 0.2s",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.12)")}
+ onMouseLeave={(e) => (e.currentTarget.style.filter = "brightness(1)")}
+ >
+ + Launch New
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
face/src/components/workspace/TermPane.tsx +274 -0
@@ -0,0 +1,274 @@
+"use client";
+
+import { useEffect } from "react";
+import { useTermSocket } from "@/hooks/useTermSocket";
+import type { TermCreds, LaunchSpec, AgentKind } from "@/lib/term";
+import { AGENT_LABEL } from "@/lib/term";
+
+export interface PaneState {
+ id: string;
+ spec: LaunchSpec;
+}
+
+interface TermPaneProps {
+ pane: PaneState;
+ creds: TermCreds | null;
+ visible: boolean;
+ focused: boolean;
+ onFocus: () => void;
+ onClose: () => void;
+ onAssign?: (paneId: string) => void;
+ // lets WorkspaceClient hold a per-pane fit() so gutter drags can reflow text
+ registerFit?: (paneId: string, fitFn: (() => void) | null) => void;
+ index: number;
+}
+
+const STATUS_META: Record<string, { label: string; color: string; pulse: boolean }> = {
+ idle: { label: "IDLE", color: "#4a525e", pulse: false },
+ connecting: { label: "CONNECTING", color: "#e8c468", pulse: true },
+ live: { label: "LIVE", color: "#16e0c8", pulse: true },
+ closed: { label: "CLOSED", color: "#4a525e", pulse: false },
+ error: { label: "ERROR", color: "#ff6b6b", pulse: false },
+};
+
+export default function TermPane({
+ pane,
+ creds,
+ visible,
+ focused,
+ onFocus,
+ onClose,
+ onAssign,
+ registerFit,
+ index,
+}: TermPaneProps) {
+ const { containerRef, status, sessionName, fit, fontSize, setFontSize } = useTermSocket({
+ creds,
+ spec: pane.spec,
+ visible,
+ });
+
+ // expose our guarded fit() to the workspace so PanelGroup onLayout / window
+ // resize can reflow this pane even if the ResizeObserver missed the change.
+ useEffect(() => {
+ if (!registerFit) return;
+ registerFit(pane.id, fit);
+ return () => {
+ registerFit(pane.id, null);
+ };
+ }, [pane.id, fit, registerFit]);
+
+ const agent = pane.spec.agent as AgentKind;
+ const meta = STATUS_META[status] ?? STATUS_META.idle;
+
+ return (
+ <div
+ onMouseDown={onFocus}
+ className="flex h-full w-full flex-col overflow-hidden"
+ style={{
+ background: "#050607",
+ border: focused
+ ? "1px solid rgba(22,224,200,0.45)"
+ : "1px solid rgba(22,224,200,0.10)",
+ boxShadow: focused ? "0 0 0 1px rgba(22,224,200,0.10), inset 0 0 80px rgba(22,224,200,0.02)" : "none",
+ transition: "border-color 0.3s cubic-bezier(0.16,1,0.3,1)",
+ }}
+ >
+ {/* ── Pane header: brutalist mono chrome ── */}
+ <div
+ className="flex shrink-0 items-center justify-between gap-2 px-3"
+ style={{
+ height: 34,
+ background: "rgba(10,12,14,0.92)",
+ borderBottom: "1px solid rgba(22,224,200,0.10)",
+ }}
+ >
+ <div className="flex min-w-0 items-center gap-2.5">
+ <span
+ className="font-mono"
+ style={{ fontSize: "0.6rem", letterSpacing: "0.12em", color: "#4a525e" }}
+ >
+ {String(index + 1).padStart(2, "0")}
+ </span>
+ <span
+ className="font-display font-bold uppercase"
+ style={{ fontSize: "0.72rem", letterSpacing: "0.06em", color: "#e8eaed" }}
+ >
+ {AGENT_LABEL[agent]}
+ </span>
+ <span
+ className="font-mono truncate"
+ style={{ fontSize: "0.62rem", letterSpacing: "0.04em", color: "#4a525e" }}
+ >
+ {pane.spec.target}
+ {pane.spec.cwd ? ` · ${pane.spec.cwd.split("/").pop() || pane.spec.cwd}` : ""}
+ </span>
+ </div>
+
+ <div className="flex shrink-0 items-center gap-3">
+ {sessionName && (
+ <span
+ className="font-mono hidden sm:inline truncate"
+ style={{ fontSize: "0.56rem", letterSpacing: "0.06em", color: "#0d9e8e", maxWidth: 130 }}
+ title={`tmux session - reattach: ${sessionName}`}
+ >
+ ⟲ {sessionName}
+ </span>
+ )}
+ {onAssign && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onAssign(pane.id);
+ }}
+ title="Assign an existing session to this pane"
+ className="font-mono uppercase"
+ style={{
+ fontSize: "0.56rem",
+ letterSpacing: "0.08em",
+ color: "#16e0c8",
+ padding: "2px 5px",
+ border: "1px solid rgba(22,224,200,0.24)",
+ transition: "background 0.2s",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(22,224,200,0.10)")}
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
+ >
+ ⇄ ASSIGN
+ </button>
+ )}
+ {/* per-pane font-size control - persisted per device */}
+ <span
+ className="flex shrink-0 items-center font-mono"
+ style={{ border: "1px solid rgba(22,224,200,0.24)" }}
+ >
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ setFontSize(fontSize - 1);
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ title="Smaller font"
+ className="flex items-center justify-center"
+ style={{
+ height: 22,
+ minWidth: 22,
+ padding: "0 4px",
+ fontSize: "0.56rem",
+ lineHeight: 1,
+ color: fontSize <= 9 ? "#4a525e" : "#8b929e",
+ background: "transparent",
+ transition: "background 0.2s, color 0.2s",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.background = "rgba(22,224,200,0.10)";
+ e.currentTarget.style.color = "#16e0c8";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent";
+ e.currentTarget.style.color = fontSize <= 9 ? "#4a525e" : "#8b929e";
+ }}
+ >
+ A−
+ </button>
+ <span
+ title="Terminal font size (px)"
+ className="flex items-center justify-center"
+ style={{
+ height: 22,
+ minWidth: 16,
+ padding: "0 5px",
+ fontSize: "0.56rem",
+ letterSpacing: "0.02em",
+ lineHeight: 1,
+ color: "#16e0c8",
+ borderLeft: "1px solid rgba(22,224,200,0.18)",
+ borderRight: "1px solid rgba(22,224,200,0.18)",
+ }}
+ >
+ {fontSize}
+ </span>
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ setFontSize(fontSize + 1);
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ title="Larger font"
+ className="flex items-center justify-center"
+ style={{
+ height: 22,
+ minWidth: 22,
+ padding: "0 4px",
+ fontSize: "0.56rem",
+ lineHeight: 1,
+ color: fontSize >= 20 ? "#4a525e" : "#8b929e",
+ background: "transparent",
+ transition: "background 0.2s, color 0.2s",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.background = "rgba(22,224,200,0.10)";
+ e.currentTarget.style.color = "#16e0c8";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent";
+ e.currentTarget.style.color = fontSize >= 20 ? "#4a525e" : "#8b929e";
+ }}
+ >
+ A+
+ </button>
+ </span>
+ <span className="flex items-center gap-1.5">
+ <span
+ className="rounded-full"
+ style={{
+ width: 6,
+ height: 6,
+ background: meta.color,
+ boxShadow: meta.pulse ? `0 0 8px ${meta.color}` : "none",
+ animation: meta.pulse ? "glow-breathe 2.4s ease-in-out infinite" : "none",
+ }}
+ />
+ <span
+ className="font-mono"
+ style={{ fontSize: "0.54rem", letterSpacing: "0.10em", color: meta.color }}
+ >
+ {meta.label}
+ </span>
+ </span>
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onClose();
+ }}
+ aria-label="Close pane"
+ className="flex items-center justify-center"
+ style={{
+ width: 22,
+ height: 22,
+ color: "#4a525e",
+ transition: "color 0.2s",
+ }}
+ onMouseEnter={(e) => (e.currentTarget.style.color = "#ff6b6b")}
+ onMouseLeave={(e) => (e.currentTarget.style.color = "#4a525e")}
+ >
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
+ <path d="M1 1L10 10M10 1L1 10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {/* ── Terminal body - MUST have real height for scrollback ── */}
+ <div className="relative min-h-0 flex-1">
+ <div
+ ref={containerRef}
+ className="absolute inset-0"
+ style={{ padding: "2px 4px", overflow: "hidden" }}
+ />
+ </div>
+ </div>
+ );
+}
face/src/components/workspace/WorkspaceClient.tsx +635 -0
@@ -0,0 +1,635 @@
+"use client";
+
+import { useEffect, useRef, useState, useCallback, useMemo } from "react";
+import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
+import Launcher from "./Launcher";
+import TermPane, { type PaneState } from "./TermPane";
+import SessionPicker from "./SessionPicker";
+import type {
+ TargetsResponse,
+ TermCreds,
+ LaunchSpec,
+ LiveSession,
+ AgentKind,
+} from "@/lib/term";
+import { fetchTermCreds, fetchTargets, killSession } from "@/lib/term";
+
+let PANE_SEQ = 0;
+const nextId = () => `pane-${++PANE_SEQ}-${Date.now().toString(36)}`;
+
+// ── resize gutter (vertical = column divider, horizontal = row divider) ──
+function Gutter({ dir }: { dir: "v" | "h" }) {
+ return (
+ <PanelResizeHandle
+ className={dir === "v" ? "w-px group relative" : "h-px group relative"}
+ style={{ background: "rgba(22,224,200,0.10)" }}
+ >
+ <div
+ className="absolute inset-0 transition-colors duration-200 group-hover:bg-teal-bio group-data-[resize-handle-state=drag]:bg-teal-bio"
+ style={
+ dir === "v"
+ ? { left: -3, right: -3, cursor: "col-resize" }
+ : { top: -3, bottom: -3, cursor: "row-resize" }
+ }
+ />
+ <div
+ className="absolute opacity-0 group-hover:opacity-100 group-data-[resize-handle-state=drag]:opacity-100 transition-opacity"
+ style={
+ dir === "v"
+ ? { top: "50%", left: -1.5, width: 4, height: 28, transform: "translateY(-50%)", background: "#16e0c8", boxShadow: "0 0 10px #16e0c8" }
+ : { left: "50%", top: -1.5, height: 4, width: 28, transform: "translateX(-50%)", background: "#16e0c8", boxShadow: "0 0 10px #16e0c8" }
+ }
+ />
+ </PanelResizeHandle>
+ );
+}
+
+interface DesktopSplitProps {
+ panes: PaneState[];
+ creds: TermCreds | null;
+ focusedId: string | null;
+ setFocusedId: (id: string) => void;
+ closePane: (id: string) => void;
+ onAssign: (paneId: string) => void;
+ registerFit: (paneId: string, fitFn: (() => void) | null) => void;
+ onLayout: () => void;
+}
+
+// Layouts: 1 / 2 (cols) / 3 (one tall left + two stacked right) / 4 (2x2)
+function DesktopSplit({
+ panes,
+ creds,
+ focusedId,
+ setFocusedId,
+ closePane,
+ onAssign,
+ registerFit,
+ onLayout,
+}: DesktopSplitProps) {
+ const n = panes.length;
+
+ const renderPane = (p: PaneState, idx: number) => (
+ <TermPane
+ key={p.id}
+ pane={p}
+ index={idx}
+ creds={creds}
+ visible
+ focused={focusedId === p.id}
+ onFocus={() => setFocusedId(p.id)}
+ onClose={() => closePane(p.id)}
+ onAssign={onAssign}
+ registerFit={registerFit}
+ />
+ );
+
+ if (n === 0) return <EmptyState />;
+ if (n === 1) return <div className="h-full w-full p-1">{renderPane(panes[0], 0)}</div>;
+
+ if (n === 2) {
+ return (
+ <PanelGroup direction="horizontal" className="p-1" autoSaveId="dw-split-2" onLayout={onLayout}>
+ <Panel defaultSize={50} minSize={18} className="p-px">{renderPane(panes[0], 0)}</Panel>
+ <Gutter dir="v" />
+ <Panel defaultSize={50} minSize={18} className="p-px">{renderPane(panes[1], 1)}</Panel>
+ </PanelGroup>
+ );
+ }
+
+ if (n === 3) {
+ return (
+ <PanelGroup direction="horizontal" className="p-1" autoSaveId="dw-split-3" onLayout={onLayout}>
+ <Panel defaultSize={50} minSize={18} className="p-px">{renderPane(panes[0], 0)}</Panel>
+ <Gutter dir="v" />
+ <Panel defaultSize={50} minSize={18} className="p-px">
+ <PanelGroup direction="vertical" autoSaveId="dw-split-3r" onLayout={onLayout}>
+ <Panel defaultSize={50} minSize={14} className="p-px pt-0 pb-px">{renderPane(panes[1], 1)}</Panel>
+ <Gutter dir="h" />
+ <Panel defaultSize={50} minSize={14} className="p-px pb-0 pt-px">{renderPane(panes[2], 2)}</Panel>
+ </PanelGroup>
+ </Panel>
+ </PanelGroup>
+ );
+ }
+
+ // 4-up: 2x2
+ return (
+ <PanelGroup direction="horizontal" className="p-1" autoSaveId="dw-split-4" onLayout={onLayout}>
+ <Panel defaultSize={50} minSize={18} className="p-px">
+ <PanelGroup direction="vertical" autoSaveId="dw-split-4l" onLayout={onLayout}>
+ <Panel defaultSize={50} minSize={14} className="p-px pt-0 pb-px">{renderPane(panes[0], 0)}</Panel>
+ <Gutter dir="h" />
+ <Panel defaultSize={50} minSize={14} className="p-px pb-0 pt-px">{renderPane(panes[2], 2)}</Panel>
+ </PanelGroup>
+ </Panel>
+ <Gutter dir="v" />
+ <Panel defaultSize={50} minSize={18} className="p-px">
+ <PanelGroup direction="vertical" autoSaveId="dw-split-4r" onLayout={onLayout}>
+ <Panel defaultSize={50} minSize={14} className="p-px pt-0 pb-px">{renderPane(panes[1], 1)}</Panel>
+ <Gutter dir="h" />
+ <Panel defaultSize={50} minSize={14} className="p-px pb-0 pt-px">{renderPane(panes[3], 3)}</Panel>
+ </PanelGroup>
+ </Panel>
+ </PanelGroup>
+ );
+}
+
+function EmptyState() {
+ return (
+ <div className="flex h-full w-full items-center justify-center p-8">
+ <div className="text-center max-w-sm">
+ <div
+ className="mx-auto mb-6"
+ style={{ width: 46, height: 46, border: "1px solid rgba(22,224,200,0.3)", display: "grid", placeItems: "center" }}
+ >
+ <span style={{ color: "#16e0c8", fontSize: "1.4rem" }}>▸</span>
+ </div>
+ <h3 className="font-display font-bold uppercase" style={{ fontSize: "1.1rem", color: "#e8eaed", letterSpacing: "0.02em" }}>
+ No agents running
+ </h3>
+ <p className="font-mono" style={{ fontSize: "0.66rem", color: "#4a525e", marginTop: 10, lineHeight: 1.6, letterSpacing: "0.03em" }}>
+ Pick a source + agent in the launcher and hit Launch. Add up to four panes and drag the
+ gutters to resize.
+ </p>
+ </div>
+ </div>
+ );
+}
+
+// ── Mobile single-pane swipeable tabs ──
+interface MobileTabsProps {
+ panes: PaneState[];
+ creds: TermCreds | null;
+ active: number;
+ setActive: (i: number) => void;
+ closePane: (id: string) => void;
+ onAssign: (paneId: string) => void;
+ registerFit: (paneId: string, fitFn: (() => void) | null) => void;
+}
+
+function MobileTabs({
+ panes,
+ creds,
+ active,
+ setActive,
+ closePane,
+ onAssign,
+ registerFit,
+}: MobileTabsProps) {
+ const touchStart = useRef<number | null>(null);
+
+ const onTouchStart = (e: React.TouchEvent) => {
+ touchStart.current = e.touches[0].clientX;
+ };
+ const onTouchEnd = (e: React.TouchEvent) => {
+ if (touchStart.current === null) return;
+ const dx = e.changedTouches[0].clientX - touchStart.current;
+ if (Math.abs(dx) > 60) {
+ if (dx < 0 && active < panes.length - 1) setActive(active + 1);
+ if (dx > 0 && active > 0) setActive(active - 1);
+ }
+ touchStart.current = null;
+ };
+
+ if (panes.length === 0)
+ return (
+ <div className="h-full">
+ <EmptyState />
+ </div>
+ );
+
+ const clamped = Math.min(active, panes.length - 1);
+
+ return (
+ <div className="flex h-full flex-col">
+ {/* tab strip */}
+ <div
+ className="flex shrink-0 overflow-x-auto"
+ style={{ background: "#0a0c0e", borderBottom: "1px solid rgba(22,224,200,0.10)" }}
+ >
+ {panes.map((p, i) => {
+ const on = i === clamped;
+ return (
+ <button
+ key={p.id}
+ onClick={() => setActive(i)}
+ className="font-display font-bold uppercase shrink-0"
+ style={{
+ padding: "11px 16px",
+ fontSize: "0.68rem",
+ letterSpacing: "0.05em",
+ color: on ? "#16e0c8" : "#4a525e",
+ borderBottom: on ? "2px solid #16e0c8" : "2px solid transparent",
+ minHeight: 44,
+ }}
+ >
+ {String(i + 1).padStart(2, "0")} · {p.spec.agent.toUpperCase()}
+ </button>
+ );
+ })}
+ </div>
+ {/* active pane (others kept mounted but hidden → visible=false guards fit) */}
+ <div className="relative min-h-0 flex-1" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
+ {panes.map((p, i) => (
+ <div
+ key={p.id}
+ className="absolute inset-0"
+ style={{ visibility: i === clamped ? "visible" : "hidden", padding: 6 }}
+ >
+ <TermPane
+ pane={p}
+ index={i}
+ creds={creds}
+ visible={i === clamped}
+ focused={i === clamped}
+ onFocus={() => setActive(i)}
+ onClose={() => closePane(p.id)}
+ onAssign={onAssign}
+ registerFit={registerFit}
+ />
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+export default function WorkspaceClient() {
+ const [creds, setCreds] = useState<TermCreds | null>(null);
+ const [targets, setTargets] = useState<TargetsResponse | null>(null);
+ const [credErr, setCredErr] = useState<string | null>(null);
+
+ const [panes, setPanes] = useState<PaneState[]>([]);
+ const [focusedId, setFocusedId] = useState<string | null>(null);
+ const [collapsed, setCollapsed] = useState(false);
+ const [isMobile, setIsMobile] = useState(false);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [mobileActive, setMobileActive] = useState(0);
+ const [pickerForPane, setPickerForPane] = useState<string | null>(null);
+ // visualViewport height on mobile (pins workspace above the keyboard).
+ // null on desktop / SSR → existing fixed inset-0 layout unchanged.
+ const [viewportH, setViewportH] = useState<number | null>(null);
+
+ // paneId → guarded fit() callback, registered by each TermPane on mount.
+ // Lets a PanelGroup gutter drag (onLayout) / window resize refit + tell the
+ // PTY its new cols/rows even when the ResizeObserver misses the change.
+ const fitFnsRef = useRef<Map<string, () => void>>(new Map());
+ const refitScheduledRef = useRef(false);
+
+ const registerFit = useCallback((paneId: string, fitFn: (() => void) | null) => {
+ if (fitFn) fitFnsRef.current.set(paneId, fitFn);
+ else fitFnsRef.current.delete(paneId);
+ }, []);
+
+ // refit every registered pane on the next animation frame (debounced)
+ const refitAll = useCallback(() => {
+ if (refitScheduledRef.current) return;
+ refitScheduledRef.current = true;
+ requestAnimationFrame(() => {
+ refitScheduledRef.current = false;
+ fitFnsRef.current.forEach((fn) => {
+ try {
+ fn();
+ } catch {
+ /* ignore */
+ }
+ });
+ });
+ }, []);
+
+ // load creds + targets, then honor any ?session= deep-link (Attach from the
+ // Neural Interface launches that exact session straight into a pane).
+ useEffect(() => {
+ (async () => {
+ const c = await fetchTermCreds();
+ if (!c.ok) {
+ setCredErr(c.error || "term backend unavailable");
+ return;
+ }
+ setCreds(c);
+ try {
+ const t = await fetchTargets(c);
+ setTargets(t);
+ } catch {
+ setCredErr("could not reach darwin-term /targets");
+ }
+
+ if (typeof window !== "undefined") {
+ const p = new URLSearchParams(window.location.search);
+ const session = p.get("session");
+ if (session) {
+ const agentParam = (p.get("agent") as LaunchSpec["agent"]) || "claude";
+ launchRef.current?.({
+ target: p.get("target") || "ct215",
+ agent: agentParam,
+ mode: "persistent",
+ cwd: "",
+ session,
+ });
+ // strip the query so a reload doesn't relaunch
+ window.history.replaceState({}, "", "/workspace");
+ }
+ }
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // responsive
+ useEffect(() => {
+ const mq = window.matchMedia("(max-width: 820px)");
+ const apply = () => setIsMobile(mq.matches);
+ apply();
+ mq.addEventListener("change", apply);
+ return () => mq.removeEventListener("change", apply);
+ }, []);
+
+ // Mobile: pin the workspace height to window.visualViewport.height so the
+ // terminal body sits above the on-screen keyboard (no shoot-up / dead space)
+ // and xterm refits when the viewport changes. Desktop/SSR keep viewportH null
+ // so the existing fixed inset-0 layout is unchanged.
+ useEffect(() => {
+ if (!isMobile) {
+ setViewportH(null);
+ return;
+ }
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
+ if (!vv) return;
+ const handler = () => {
+ setViewportH(vv.height);
+ refitAll();
+ };
+ handler();
+ vv.addEventListener("resize", handler);
+ vv.addEventListener("scroll", handler);
+ return () => {
+ vv.removeEventListener("resize", handler);
+ vv.removeEventListener("scroll", handler);
+ };
+ }, [isMobile, refitAll]);
+
+ // refit every pane on window resize (debounced via rAF)
+ useEffect(() => {
+ const onResize = () => refitAll();
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, [refitAll]);
+
+ // refit after a split-count change or sidebar collapse toggle so the text
+ // reflows into the new geometry once layout settles.
+ useEffect(() => {
+ refitAll();
+ }, [panes.length, collapsed, refitAll]);
+
+ const launch = useCallback((spec: LaunchSpec) => {
+ setPanes((prev) => {
+ if (prev.length >= 4) return prev; // cap at 4-up
+ const pane: PaneState = { id: nextId(), spec };
+ setFocusedId(pane.id);
+ setMobileActive(prev.length);
+ return [...prev, pane];
+ });
+ setDrawerOpen(false);
+ }, []);
+
+ // keep a stable ref so the mount effect can launch a deep-linked session
+ const launchRef = useRef(launch);
+ useEffect(() => { launchRef.current = launch; }, [launch]);
+
+ const closePane = useCallback((id: string) => {
+ setPanes((prev) => prev.filter((p) => p.id !== id));
+ }, []);
+
+ // ── per-pane session picker (FIX 1) ──
+ const assignPane = useCallback((paneId: string) => {
+ setPickerForPane(paneId);
+ }, []);
+
+ const setPaneSpec = useCallback((paneId: string, spec: LaunchSpec) => {
+ setPanes((prev) => prev.map((p) => (p.id === paneId ? { ...p, spec } : p)));
+ setPickerForPane(null);
+ }, []);
+
+ // sessions currently displayed in some pane → drive the gold "IN PANE" dots
+ const openSessionIds = useMemo(
+ () =>
+ new Set(
+ panes.map((p) => p.spec.session).filter((x): x is string => Boolean(x))
+ ),
+ [panes]
+ );
+
+ const onPickerPick = useCallback(
+ (s: LiveSession) => {
+ if (!pickerForPane) return;
+ setPaneSpec(pickerForPane, {
+ target: s.target,
+ agent: (s.agent as AgentKind) || "shell",
+ mode: "persistent",
+ cwd: "",
+ session: s.session,
+ });
+ },
+ [pickerForPane, setPaneSpec]
+ );
+
+ const onPickerClose = useCallback(() => setPickerForPane(null), []);
+ // "Launch New" just dismisses the picker - the sidebar Launcher still spawns.
+ const onPickerLaunchNew = useCallback(() => setPickerForPane(null), []);
+
+ const onKillSession = useCallback(
+ async (s: LiveSession) => {
+ if (creds) await killSession(creds, s.target, s.session);
+ },
+ [creds]
+ );
+
+ const setSplit = (target: number) => {
+ let firstNewId: string | null = null;
+ setPanes((prev) => {
+ if (target === prev.length) return prev;
+ if (target < prev.length) return prev.slice(0, target);
+ // need more panes → clone the last spec (or default) for the new slots,
+ // but WITHOUT a session so they're empty panes awaiting an assign from
+ // the session picker (drop `session` deliberately).
+ const base = prev[prev.length - 1]?.spec ??
+ ({ target: targets?.targets[0]?.key ?? "ct215", agent: "shell", mode: "persistent", cwd: "" } as LaunchSpec);
+ const additions: PaneState[] = [];
+ for (let i = prev.length; i < target; i++) {
+ additions.push({
+ id: nextId(),
+ spec: { target: base.target, agent: base.agent, mode: base.mode, cwd: base.cwd },
+ });
+ }
+ // auto-open the picker for the first newly-split empty pane
+ if (additions[0]) firstNewId = additions[0].id;
+ return [...prev, ...additions];
+ });
+ if (firstNewId) setPickerForPane(firstNewId);
+ };
+
+ const sidebar = (
+ <Launcher
+ creds={creds}
+ targets={targets}
+ onLaunch={launch}
+ onKillSession={onKillSession}
+ collapsed={false}
+ />
+ );
+
+ return (
+ <div
+ data-darwin-workspace
+ className={viewportH != null ? "fixed left-0 top-0 right-0 flex flex-col" : "fixed inset-0 flex flex-col"}
+ style={viewportH != null ? { background: "#050607", height: viewportH } : { background: "#050607" }}
+ >
+ {/* ── Top bar ── */}
+ <header
+ className="flex shrink-0 items-center justify-between px-3 sm:px-5"
+ style={{ height: 52, background: "rgba(8,10,12,0.96)", borderBottom: "1px solid rgba(22,224,200,0.12)" }}
+ >
+ <div className="flex items-center gap-3 sm:gap-5 min-w-0">
+ <a href="/" className="font-mono uppercase shrink-0" style={{ fontSize: "0.62rem", letterSpacing: "0.2em", color: "#0d9e8e" }}>
+ ← DRW
+ </a>
+ <span className="hidden sm:block" style={{ width: 1, height: 18, background: "rgba(22,224,200,0.18)" }} />
+ <h1 className="font-display font-bold uppercase truncate" style={{ fontSize: "0.92rem", letterSpacing: "0.04em", color: "#e8eaed" }}>
+ Workspace
+ </h1>
+ <span className="hidden md:inline font-mono" style={{ fontSize: "0.56rem", letterSpacing: "0.1em", color: "#4a525e" }}>
+ TEAM-UP TERMINAL
+ </span>
+ </div>
+
+ <div className="flex items-center gap-2 sm:gap-3">
+ {/* split selector */}
+ <div className="hidden md:flex items-center gap-1" style={{ border: "1px solid rgba(22,224,200,0.14)", padding: 2 }}>
+ {[1, 2, 3, 4].map((k) => {
+ const on = panes.length === k;
+ return (
+ <button
+ key={k}
+ onClick={() => setSplit(k)}
+ title={`${k}-up split`}
+ className="font-mono"
+ style={{
+ width: 30, height: 26, fontSize: "0.66rem",
+ color: on ? "#050607" : "#8b929e",
+ background: on ? "#16e0c8" : "transparent",
+ transition: "all 0.2s",
+ }}
+ >
+ {k}
+ </button>
+ );
+ })}
+ </div>
+
+ {/* collapse sidebar (desktop) */}
+ {!isMobile && (
+ <button
+ onClick={() => setCollapsed((c) => !c)}
+ className="font-mono uppercase flex items-center gap-1.5"
+ style={{ fontSize: "0.58rem", letterSpacing: "0.08em", color: "#8b929e", padding: "7px 11px", border: "1px solid rgba(22,224,200,0.14)" }}
+ title={collapsed ? "Show launcher" : "Hide launcher (full-bleed pane)"}
+ >
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
+ <rect x="0.5" y="0.5" width="11" height="11" stroke="currentColor" />
+ <line x1={collapsed ? "4" : "4"} y1="0.5" x2="4" y2="11.5" stroke="currentColor" />
+ </svg>
+ {collapsed ? "PANEL" : "FULL"}
+ </button>
+ )}
+
+ {/* mobile launcher drawer trigger */}
+ {isMobile && (
+ <button
+ onClick={() => setDrawerOpen(true)}
+ className="font-display font-bold uppercase"
+ style={{ fontSize: "0.66rem", letterSpacing: "0.06em", color: "#050607", background: "#16e0c8", padding: "8px 14px", minHeight: 40 }}
+ >
+ + Launch
+ </button>
+ )}
+ </div>
+ </header>
+
+ {/* ── Body ── */}
+ <div className="relative flex min-h-0 flex-1">
+ {/* desktop sidebar */}
+ {!isMobile && !collapsed && (
+ <div className="shrink-0" style={{ width: 248 }}>
+ {sidebar}
+ </div>
+ )}
+
+ {/* terminal area */}
+ <div className="min-w-0 flex-1">
+ {isMobile ? (
+ <MobileTabs
+ panes={panes}
+ creds={creds}
+ active={mobileActive}
+ setActive={setMobileActive}
+ closePane={closePane}
+ onAssign={assignPane}
+ registerFit={registerFit}
+ />
+ ) : (
+ <DesktopSplit
+ panes={panes}
+ creds={creds}
+ focusedId={focusedId}
+ setFocusedId={setFocusedId}
+ closePane={closePane}
+ onAssign={assignPane}
+ registerFit={registerFit}
+ onLayout={refitAll}
+ />
+ )}
+ </div>
+
+ {credErr && (
+ <div className="absolute left-1/2 top-4 -translate-x-1/2 px-4 py-2 font-mono" style={{ fontSize: "0.62rem", color: "#ff6b6b", background: "rgba(10,12,14,0.95)", border: "1px solid rgba(255,107,107,0.3)", letterSpacing: "0.04em" }}>
+ ⚠ {credErr}
+ </div>
+ )}
+ </div>
+
+ {/* ── Mobile bottom-sheet launcher drawer ── */}
+ {isMobile && drawerOpen && (
+ <div className="fixed inset-0 z-50" onClick={() => setDrawerOpen(false)}>
+ <div className="absolute inset-0" style={{ background: "rgba(5,6,7,0.6)", backdropFilter: "blur(2px)" }} />
+ <div
+ onClick={(e) => e.stopPropagation()}
+ className="absolute bottom-0 left-0 right-0"
+ style={{
+ maxHeight: "88dvh",
+ overflowY: "auto",
+ background: "#080a0c",
+ borderTop: "1px solid rgba(22,224,200,0.2)",
+ boxShadow: "0 -20px 60px rgba(0,0,0,0.6)",
+ animation: "sheet-up 0.4s cubic-bezier(0.16,1,0.3,1)",
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
+ }}
+ >
+ <div className="flex justify-center pt-3 pb-1">
+ <span style={{ width: 36, height: 4, borderRadius: 2, background: "rgba(22,224,200,0.3)" }} />
+ </div>
+ {sidebar}
+ </div>
+ </div>
+ )}
+
+ {/* ── per-pane session picker overlay (FIX 1) ── */}
+ {pickerForPane && (
+ <SessionPicker
+ openSessionIds={openSessionIds}
+ onPick={onPickerPick}
+ onClose={onPickerClose}
+ onLaunchNew={onPickerLaunchNew}
+ />
+ )}
+ </div>
+ );
+}
face/src/hooks/useAsk.ts +42 -0
@@ -0,0 +1,42 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { ask } from "@/lib/mock-gateway";
+import type { AskResponse } from "@/lib/types";
+
+interface UseAskReturn {
+ data: AskResponse | null;
+ loading: boolean;
+ error: string | null;
+ submit: (query: string) => Promise<void>;
+ reset: () => void;
+}
+
+export function useAsk(): UseAskReturn {
+ const [data, setData] = useState<AskResponse | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const submit = useCallback(async (query: string) => {
+ if (!query.trim()) return;
+ setLoading(true);
+ setError(null);
+ setData(null);
+ try {
+ const result = await ask(query.trim());
+ setData(result);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const reset = useCallback(() => {
+ setData(null);
+ setError(null);
+ setLoading(false);
+ }, []);
+
+ return { data, loading, error, submit, reset };
+}
face/src/hooks/useHealth.ts +38 -0
@@ -0,0 +1,38 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { health } from "@/lib/mock-gateway";
+import type { HealthResponse } from "@/lib/types";
+
+interface UseHealthReturn {
+ data: HealthResponse | null;
+ loading: boolean;
+ error: string | null;
+ refresh: () => Promise<void>;
+}
+
+export function useHealth(pollIntervalMs = 30_000): UseHealthReturn {
+ const [data, setData] = useState<HealthResponse | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const result = await health();
+ setData(result);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Health check failed");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ const interval = setInterval(refresh, pollIntervalMs);
+ return () => clearInterval(interval);
+ }, [refresh, pollIntervalMs]);
+
+ return { data, loading, error, refresh };
+}
face/src/hooks/useTermSocket.ts +349 -0
@@ -0,0 +1,349 @@
+"use client";
+
+import { useEffect, useRef, useState, useCallback } from "react";
+import type { Terminal } from "@xterm/xterm";
+import type { FitAddon } from "@xterm/addon-fit";
+import type { TermCreds, LaunchSpec } from "@/lib/term";
+import { buildWsUrl } from "@/lib/term";
+
+export type TermStatus = "idle" | "connecting" | "live" | "closed" | "error";
+
+interface UseTermSocketArgs {
+ creds: TermCreds | null;
+ spec: LaunchSpec | null;
+ // whether this pane is currently visible (for fit-guard)
+ visible: boolean;
+}
+
+interface UseTermSocketReturn {
+ containerRef: (el: HTMLDivElement | null) => void;
+ status: TermStatus;
+ sessionName: string | null;
+ fit: () => void;
+ banner: string | null;
+ fontSize: number;
+ setFontSize: (n: number) => void;
+}
+
+const TERM_THEME = {
+ background: "#050607",
+ foreground: "#e8eaed",
+ cursor: "#16e0c8",
+ cursorAccent: "#050607",
+ selectionBackground: "rgba(22,224,200,0.22)",
+ black: "#0a0c0e",
+ red: "#ff6b6b",
+ // Dim the ANSI green/cyan so default tmux status bars (green bg) read as a
+ // restrained teal band, not a neon block. The cursor stays bright #16e0c8.
+ green: "#0d9e8e",
+ yellow: "#c9a85a",
+ blue: "#6ba8ff",
+ magenta: "#c792ea",
+ cyan: "#0d9e8e",
+ white: "#8b929e",
+ brightBlack: "#4a525e",
+ brightRed: "#ff8585",
+ brightGreen: "#16e0c8",
+ brightYellow: "#e8c468",
+ brightBlue: "#8fbcff",
+ brightMagenta: "#dcb4ff",
+ brightCyan: "#16e0c8",
+ brightWhite: "#e8eaed",
+};
+
+const TERM_FONT_KEY = "darwin-term-fontsize";
+const FONT_MIN = 9;
+const FONT_MAX = 20;
+
+function clampFontSize(n: number): number {
+ return Math.max(FONT_MIN, Math.min(FONT_MAX, Math.round(n)));
+}
+
+// Per-device persisted terminal font size. Defaults to 11 on narrow (mobile)
+// viewports and 13 elsewhere; any stored value is clamped to [9, 20].
+function readPersistedFontSize(): number {
+ if (typeof window === "undefined") return 13;
+ try {
+ const raw = window.localStorage.getItem(TERM_FONT_KEY);
+ if (raw != null) {
+ const n = parseInt(raw, 10);
+ if (Number.isFinite(n)) return clampFontSize(n);
+ }
+ } catch {
+ /* localStorage may be unavailable */
+ }
+ return window.innerWidth <= 820 ? 11 : 13;
+}
+
+export function useTermSocket({
+ creds,
+ spec,
+ visible,
+}: UseTermSocketArgs): UseTermSocketReturn {
+ const [status, setStatus] = useState<TermStatus>("idle");
+ const [sessionName, setSessionName] = useState<string | null>(null);
+ const [banner, setBanner] = useState<string | null>(null);
+
+ // Per-device font size, initialized from localStorage and kept in a ref so
+ // the Terminal-construction effect always reads the latest value.
+ const [fontSize, setFontSizeState] = useState<number>(() => readPersistedFontSize());
+ const fontSizeRef = useRef(fontSize);
+ fontSizeRef.current = fontSize;
+
+ const termRef = useRef<Terminal | null>(null);
+ const fitRef = useRef<FitAddon | null>(null);
+ const wsRef = useRef<WebSocket | null>(null);
+ const elRef = useRef<HTMLDivElement | null>(null);
+ const visibleRef = useRef(visible);
+ const lastSentSize = useRef<{ cols: number; rows: number }>({ cols: 0, rows: 0 });
+ const decoder = useRef(new TextDecoder());
+
+ visibleRef.current = visible;
+
+ // Guarded fit - only fit a visible, non-zero-size pane, else xterm sends a
+ // bogus tiny PTY resize and garbles TUIs (the Cockpit bug).
+ const fit = useCallback(() => {
+ const term = termRef.current;
+ const fitAddon = fitRef.current;
+ const el = elRef.current;
+ const ws = wsRef.current;
+ if (!term || !fitAddon || !el) return;
+ if (!visibleRef.current) return;
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 8 || rect.height < 8) return;
+
+ try {
+ fitAddon.fit();
+ } catch {
+ return;
+ }
+ const cols = term.cols;
+ const rows = term.rows;
+ if (!cols || !rows) return;
+ if (cols === lastSentSize.current.cols && rows === lastSentSize.current.rows) return;
+ lastSentSize.current = { cols, rows };
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ t: "resize", cols, rows }));
+ }
+ }, []);
+
+ // Live, per-device font-size control: clamp + persist to localStorage, apply
+ // to xterm immediately, then guarded fit() on next frame reflows cols/rows
+ // and sends the {"t":"resize"} PTY frame.
+ const setFontSize = useCallback((n: number) => {
+ const clamped = clampFontSize(n);
+ try {
+ window.localStorage.setItem(TERM_FONT_KEY, String(clamped));
+ } catch {
+ /* ignore */
+ }
+ setFontSizeState(clamped);
+ const term = termRef.current;
+ if (term) {
+ term.options.fontSize = clamped;
+ requestAnimationFrame(() => fit());
+ }
+ }, [fit]);
+
+ // Mount xterm into the container once it exists
+ const containerRef = useCallback((el: HTMLDivElement | null) => {
+ elRef.current = el;
+ }, []);
+
+ // Spin up the terminal + socket when creds + spec are ready
+ useEffect(() => {
+ if (!creds || !spec) return;
+ let disposed = false;
+ let term: Terminal | null = null;
+ let fitAddon: FitAddon | null = null;
+ let ws: WebSocket | null = null;
+ let ro: ResizeObserver | null = null;
+ let onWinResize: (() => void) | null = null;
+ let onVvResize: (() => void) | null = null;
+ let onVvScroll: (() => void) | null = null;
+ let vp: HTMLElement | null = null;
+ let onTouchStart: ((e: TouchEvent) => void) | null = null;
+ let onTouchMove: ((e: TouchEvent) => void) | null = null;
+
+ setStatus("connecting");
+ setSessionName(null);
+ setBanner(null);
+
+ (async () => {
+ const [{ Terminal }, { FitAddon }] = await Promise.all([
+ import("@xterm/xterm"),
+ import("@xterm/addon-fit"),
+ ]);
+ if (disposed || !elRef.current) return;
+
+ term = new Terminal({
+ cursorBlink: true,
+ fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
+ fontSize: fontSizeRef.current,
+ lineHeight: 1.25,
+ letterSpacing: 0,
+ scrollback: 20000,
+ theme: TERM_THEME,
+ allowProposedApi: true,
+ macOptionIsMeta: true,
+ convertEol: false,
+ });
+ fitAddon = new FitAddon();
+ term.loadAddon(fitAddon);
+ term.open(elRef.current);
+
+ termRef.current = term;
+ fitRef.current = fitAddon;
+ // expose for diagnostics (scrollback buffer inspection)
+ (elRef.current as unknown as { __term?: Terminal }).__term = term;
+
+ // initial fit (deferred to next frame so layout settles)
+ requestAnimationFrame(() => requestAnimationFrame(() => fit()));
+
+ // web-font load shifts character metrics (monospace widths change once
+ // JetBrains Mono arrives), so re-fit once fonts are ready: cols *
+ // charWidth must stay <= the visible pane width or text clips on mobile.
+ if (typeof document !== "undefined" && document.fonts && document.fonts.ready) {
+ document.fonts.ready.then(() => {
+ if (disposed) return;
+ fit();
+ });
+ }
+
+ // open the authed socket
+ const url = buildWsUrl(creds, spec);
+ ws = new WebSocket(url);
+ ws.binaryType = "arraybuffer";
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ if (disposed) return;
+ setStatus("live");
+ // push our size immediately on connect
+ requestAnimationFrame(() => fit());
+ };
+
+ ws.onmessage = (ev) => {
+ if (!termRef.current) return;
+ if (typeof ev.data === "string") {
+ // text frame - banner or JSON control
+ try {
+ const obj = JSON.parse(ev.data);
+ if (obj && obj._cockpit === "session" && obj.name) {
+ setSessionName(String(obj.name));
+ return;
+ }
+ } catch {
+ // not JSON → treat as banner text, write it
+ }
+ setBanner((b) => (b ? b : ev.data));
+ termRef.current.write(ev.data);
+ } else {
+ // binary PTY output
+ const bytes = new Uint8Array(ev.data as ArrayBuffer);
+ termRef.current.write(bytes);
+ }
+ };
+
+ ws.onclose = () => {
+ if (disposed) return;
+ setStatus("closed");
+ termRef.current?.write("\r\n\x1b[38;2;74;82;94m── session closed ──\x1b[0m\r\n");
+ };
+ ws.onerror = () => {
+ if (disposed) return;
+ setStatus("error");
+ };
+
+ // keystrokes → binary frames
+ term.onData((data) => {
+ const sock = wsRef.current;
+ if (sock && sock.readyState === WebSocket.OPEN) {
+ sock.send(new TextEncoder().encode(data));
+ }
+ });
+
+ // ── touch-scroll over history ── xterm's native touch scroll is weak and
+ // (with tmux mouse-mode off) nothing else drives the scrollback. Make the
+ // viewport touch-pannable and drive term.scrollLines() from a drag.
+ vp = elRef.current.querySelector(".xterm-viewport") as HTMLElement | null;
+ if (vp) {
+ vp.style.overflowY = "auto";
+ vp.style.setProperty("-webkit-overflow-scrolling", "touch");
+ vp.style.touchAction = "pan-y";
+ let startY = 0;
+ onTouchStart = (e: TouchEvent) => {
+ if (e.touches.length > 0) startY = e.touches[0].clientY;
+ };
+ onTouchMove = (e: TouchEvent) => {
+ const t = termRef.current;
+ if (!t || e.touches.length === 0) return;
+ const currentY = e.touches[0].clientY;
+ const dy = startY - currentY;
+ // lineHeight is 1.25 (set on the Terminal above) → cell ≈ fontSize * 1.25
+ const cellHeight = fontSizeRef.current * 1.25;
+ const lineDelta = Math.round(dy / cellHeight);
+ if (lineDelta !== 0) {
+ t.scrollLines(lineDelta);
+ startY = currentY;
+ }
+ };
+ vp.addEventListener("touchstart", onTouchStart, { passive: true });
+ vp.addEventListener("touchmove", onTouchMove, { passive: true });
+ }
+
+ // ── BUG 1 width stability ── the usable width changes when the mobile
+ // keyboard opens, the browser chrome collapses, or the device rotates.
+ // visualViewport covers the first two; window resize covers rotation.
+ // rAF-coalesced so bursts collapse into a single fit().
+ const scheduleFit = () => requestAnimationFrame(() => fit());
+ onWinResize = scheduleFit;
+ window.addEventListener("resize", onWinResize, { passive: true });
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
+ if (vv) {
+ onVvResize = scheduleFit;
+ onVvScroll = scheduleFit;
+ vv.addEventListener("resize", onVvResize);
+ vv.addEventListener("scroll", onVvScroll);
+ }
+
+ // observe size changes on the pane body
+ ro = new ResizeObserver(() => {
+ // throttle to a frame
+ requestAnimationFrame(() => fit());
+ });
+ if (elRef.current) ro.observe(elRef.current);
+ })();
+
+ return () => {
+ disposed = true;
+ try { ro?.disconnect(); } catch {}
+ if (onWinResize) window.removeEventListener("resize", onWinResize);
+ const vv = typeof window !== "undefined" ? window.visualViewport : null;
+ if (vv) {
+ if (onVvResize) vv.removeEventListener("resize", onVvResize);
+ if (onVvScroll) vv.removeEventListener("scroll", onVvScroll);
+ }
+ if (vp) {
+ if (onTouchStart) vp.removeEventListener("touchstart", onTouchStart);
+ if (onTouchMove) vp.removeEventListener("touchmove", onTouchMove);
+ }
+ try { ws?.close(); } catch {}
+ try { term?.dispose(); } catch {}
+ termRef.current = null;
+ fitRef.current = null;
+ wsRef.current = null;
+ lastSentSize.current = { cols: 0, rows: 0 };
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [creds, spec?.target, spec?.agent, spec?.mode, spec?.cwd, spec?.session]);
+
+ // re-fit when the pane becomes visible (mobile tab switch / split change)
+ useEffect(() => {
+ if (visible) {
+ requestAnimationFrame(() => requestAnimationFrame(() => fit()));
+ }
+ }, [visible, fit]);
+
+ return { containerRef, status, sessionName, fit, banner, fontSize, setFontSize };
+}
face/src/lib/markdown-lite.ts +63 -0
@@ -0,0 +1,63 @@
+// markdown-lite - a tiny, safe renderer for Cortex answer prose.
+// Supports: **bold**, *italic*, `code`, "- " / "* " / "1. " lists, paragraphs.
+// HTML is escaped FIRST so model output can never inject markup.
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;");
+}
+
+function inline(s: string): string {
+ return escapeHtml(s)
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>")
+ .replace(/`([^`]+)`/g, '<code class="ml-code">$1</code>');
+}
+
+export function renderMarkdownLite(src: string): string {
+ if (!src) return "";
+ const lines = src.replace(/\r\n/g, "\n").split("\n");
+ const out: string[] = [];
+ let list: { type: "ul" | "ol"; items: string[] } | null = null;
+ let para: string[] = [];
+
+ const flushPara = () => {
+ if (para.length) {
+ out.push(`<p>${inline(para.join(" "))}</p>`);
+ para = [];
+ }
+ };
+ const flushList = () => {
+ if (list) {
+ const inner = list.items.map((it) => `<li>${inline(it)}</li>`).join("");
+ out.push(`<${list.type} class="ml-list">${inner}</${list.type}>`);
+ list = null;
+ }
+ };
+
+ for (const raw of lines) {
+ const line = raw.trimEnd();
+ const ulm = line.match(/^\s*[-*+]\s+(.*)$/);
+ const olm = line.match(/^\s*\d+\.\s+(.*)$/);
+ if (ulm) {
+ flushPara();
+ if (!list || list.type !== "ul") { flushList(); list = { type: "ul", items: [] }; }
+ list.items.push(ulm[1]);
+ } else if (olm) {
+ flushPara();
+ if (!list || list.type !== "ol") { flushList(); list = { type: "ol", items: [] }; }
+ list.items.push(olm[1]);
+ } else if (line.trim() === "") {
+ flushPara();
+ flushList();
+ } else {
+ flushList();
+ para.push(line.replace(/^#{1,6}\s*/, ""));
+ }
+ }
+ flushPara();
+ flushList();
+ return out.join("");
+}
face/src/lib/mock-gateway.ts +173 -0
@@ -0,0 +1,173 @@
+// Darwin Face - Mock Gateway
+// Typed mocks matching the Darwin Cortex API contracts.
+// Falls back to these when NEXT_PUBLIC_CORTEX_URL is unset or requests fail.
+
+import type { AskResponse, HealthResponse } from "./types";
+
+// ─── Mock data ────────────────────────────────────────────────────────────────
+
+const MOCK_ASK_RESPONSE: AskResponse = {
+ answer:
+ "Hermes v2.5 is running on CT-REDACTED (REDACTED-IP). The service is active and auto-restarting via systemd. " +
+ "Last deployment was from /shared/projects/hermes/main.py. The bot uses RSA-PSS auth against the Kalshi API " +
+ "and Grok's /v1/responses endpoint with web_search + x_search tools for market intelligence. " +
+ "Auto-execute trading is enabled. The radar v2 pipeline handles signal generation.",
+ receipts: [
+ {
+ id: "r1",
+ source: "rag",
+ path: "/shared/projects/CLAUDE.md",
+ last_seen: "2026-06-23T18:42:00Z",
+ confidence: 0.96,
+ snippet:
+ "Hermes v2.5 - Kalshi Trading Bot (CT-REDACTED). Code: $HERMES_HOME/main.py on CT-REDACTED (3883 lines). Service: systemd hermes (enabled, auto-restarts).",
+ },
+ {
+ id: "r2",
+ source: "cbm",
+ path: "/shared/projects/hermes/main.py#RadarV2Pipeline",
+ last_seen: "2026-06-23T17:15:00Z",
+ confidence: 0.89,
+ snippet:
+ "class RadarV2Pipeline: async def run(self, market_id: str) -> Signal: ... # Grok web_search + x_search, RSA-PSS Kalshi auth",
+ },
+ {
+ id: "r3",
+ source: "memgraph",
+ path: "entity:hermes-trading-bot",
+ last_seen: "2026-06-23T16:00:00Z",
+ confidence: 0.82,
+ snippet: "Hermes v2.5 deployed CT-REDACTED pve-gpu node. Auto-execute enabled. Last trade signal 14m ago.",
+ },
+ {
+ id: "r4",
+ source: "proxmox",
+ path: "ct/210/status",
+ last_seen: "2026-06-23T18:55:00Z",
+ confidence: 0.99,
+ snippet: "CT-REDACTED status: running. CPU 1.2% / 2c. RAM 812MB / 2GB. Uptime 4d 7h 22m.",
+ },
+ ],
+ hits: [
+ {
+ engine: "homelab-rag",
+ title: "Hermes Trading Bot - CLAUDE.md",
+ path: "/shared/projects/CLAUDE.md",
+ snippet: "Hermes v2.5 - Kalshi Trading Bot (CT-REDACTED). Stack: Python - Discord bot + FastAPI + Kalshi API + Grok Responses API",
+ score: 0.97,
+ last_seen: "2026-06-23T18:42:00Z",
+ confidence: 0.96,
+ },
+ {
+ engine: "cbm-homelab",
+ title: "RadarV2Pipeline class",
+ path: "/shared/projects/hermes/main.py",
+ snippet: "class RadarV2Pipeline - signal generation, Grok API integration, market scoring",
+ score: 0.91,
+ last_seen: "2026-06-23T17:15:00Z",
+ confidence: 0.89,
+ },
+ {
+ engine: "proxmox-api",
+ title: "CT-REDACTED - hermes container",
+ path: "pve-gpu/lxc/210",
+ snippet: "Container running on pve-gpu (REDACTED-IP). Resources: 2c/2GB/30GB.",
+ score: 0.88,
+ last_seen: "2026-06-23T18:55:00Z",
+ confidence: 0.99,
+ },
+ {
+ engine: "memory-graph",
+ title: "Hermes entity",
+ path: "graph:hermes-v25",
+ snippet: "Bot active. Auto-execute mode. Last deploy 2026-06-22. Radar v2 pipeline live.",
+ score: 0.84,
+ last_seen: "2026-06-23T16:00:00Z",
+ confidence: 0.82,
+ },
+ {
+ engine: "serena",
+ title: "KalshiClient.place_order()",
+ path: "/shared/projects/hermes/main.py#L2104",
+ snippet: "async def place_order(self, ticker: str, count: int, side: str, price: float) -> dict",
+ score: 0.79,
+ last_seen: "2026-06-23T14:30:00Z",
+ confidence: 0.77,
+ },
+ ],
+ conflicts: [],
+ engines: [
+ { engine: "homelab-rag", status: "up", ms: 42 },
+ { engine: "cbm-homelab", status: "up", ms: 88 },
+ { engine: "serena", status: "up", ms: 134 },
+ { engine: "memory-graph", status: "up", ms: 22 },
+ { engine: "proxmox-api", status: "up", ms: 61 },
+ ],
+ took_ms: 312,
+};
+
+const MOCK_HEALTH_RESPONSE: HealthResponse = {
+ engines: [
+ { engine: "proxmox", status: "up", last_ingest: "2026-06-23T18:55:00Z", staleness_h: 0.08 },
+ { engine: "white-pc", status: "up", last_ingest: "2026-06-23T17:30:00Z", staleness_h: 1.4 },
+ { engine: "3060", status: "up", last_ingest: "2026-06-23T18:45:00Z", staleness_h: 0.17 },
+ { engine: "browser-history", status: "down", last_ingest: "2026-06-22T08:00:00Z", staleness_h: 34.9 },
+ { engine: "agents", status: "up", last_ingest: "2026-06-23T18:58:00Z", staleness_h: 0.03 },
+ { engine: "rag", status: "up", last_ingest: "2026-06-23T18:50:00Z", staleness_h: 0.13 },
+ { engine: "cbm", status: "up", last_ingest: "2026-06-23T17:15:00Z", staleness_h: 1.73 },
+ { engine: "serena", status: "up", last_ingest: "2026-06-23T18:00:00Z", staleness_h: 0.97 },
+ { engine: "memgraph", status: "up", last_ingest: "2026-06-23T16:00:00Z", staleness_h: 2.97 },
+ { engine: "inbox", status: "up", last_ingest: "2026-06-23T18:59:00Z", staleness_h: 0.02 },
+ ],
+ ok: true,
+};
+
+// ─── API client ───────────────────────────────────────────────────────────────
+
+// LIVE defaults: Cortex runs on CT252 (REDACTED-IP:9890).
+// NEXT_PUBLIC_ vars must be set at BUILD time; runtime systemd env vars won't
+// propagate into the client bundle. So we hard-code the homelab URL as the
+// primary and fall through to mock ONLY if the live endpoint is unreachable.
+const CORTEX_URL_DEFAULT = "http://REDACTED-IP:9890";
+
+function getCortexUrl(): string {
+ if (typeof window === "undefined") return CORTEX_URL_DEFAULT;
+ return (process.env.NEXT_PUBLIC_CORTEX_URL as string) || CORTEX_URL_DEFAULT;
+}
+
+export async function ask(query: string): Promise<AskResponse> {
+ const base = getCortexUrl();
+ try {
+ const res = await fetch(`${base}/ask`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ query }),
+ signal: AbortSignal.timeout(30_000),
+ cache: "no-store",
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return (await res.json()) as AskResponse;
+ } catch (err) {
+ console.warn("[darwin] /ask failed, using mock:", err);
+ return simulateDelay(MOCK_ASK_RESPONSE);
+ }
+}
+
+export async function health(): Promise<HealthResponse> {
+ const base = getCortexUrl();
+ try {
+ const res = await fetch(`${base}/health`, {
+ signal: AbortSignal.timeout(8_000),
+ cache: "no-store",
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return (await res.json()) as HealthResponse;
+ } catch (err) {
+ console.warn("[darwin] /health failed, using mock:", err);
+ return simulateDelay(MOCK_HEALTH_RESPONSE, 200);
+ }
+}
+
+function simulateDelay<T>(data: T, ms = 800): Promise<T> {
+ return new Promise((resolve) => setTimeout(() => resolve(data), ms));
+}
face/src/lib/term.ts +99 -0
@@ -0,0 +1,99 @@
+// darwin-term protocol client helpers + types
+
+export type AgentKind = "claude" | "codex" | "opencode" | "shell";
+export type TermMode = "persistent" | "ephemeral";
+
+export interface TermDir {
+ label: string;
+ path: string;
+}
+
+export interface TermTarget {
+ key: string; // ct215 | ct241 | ct247
+ ip: string;
+ label: string;
+ agents: AgentKind[];
+ default_agent: AgentKind;
+ default_cwd: string;
+ dirs: TermDir[];
+}
+
+export interface TargetsResponse {
+ targets: TermTarget[];
+ agent_cmds: Record<string, string>;
+ common_dirs: TermDir[];
+}
+
+export interface LiveSession {
+ target: string;
+ label: string;
+ session: string;
+ agent: string;
+ created: string;
+ attached: boolean;
+}
+
+export interface TermCreds {
+ ok: boolean;
+ token: string;
+ host: string; // host:port
+ error?: string;
+}
+
+// Pretty labels for agents (used by the launcher + pane chrome)
+export const AGENT_LABEL: Record<AgentKind, string> = {
+ claude: "CLAUDE",
+ codex: "CODEX",
+ opencode: "GLM",
+ shell: "SHELL",
+};
+
+// Fetch server-injected creds (token kept server-side; route returns it at runtime)
+export async function fetchTermCreds(): Promise<TermCreds> {
+ const res = await fetch("/api/term", { cache: "no-store" });
+ return res.json();
+}
+
+// HTTP control calls go through same-origin Next route handlers, which inject
+// the token server-side and proxy to darwin-term. This avoids CORS and keeps
+// the token off the wire for these calls. (creds kept in the signature so the
+// hook can stay agnostic; not used here.)
+export async function fetchTargets(_creds: TermCreds): Promise<TargetsResponse> {
+ const res = await fetch(`/api/term/targets`, { cache: "no-store" });
+ return res.json();
+}
+
+export async function fetchSessions(_creds: TermCreds): Promise<LiveSession[]> {
+ const res = await fetch(`/api/term/sessions`, { cache: "no-store" });
+ const data = await res.json();
+ return data.sessions ?? [];
+}
+
+export async function killSession(
+ _creds: TermCreds,
+ target: string,
+ session: string
+): Promise<void> {
+ const p = new URLSearchParams({ target, session });
+ await fetch(`/api/term/kill?${p.toString()}`, { method: "POST" });
+}
+
+export interface LaunchSpec {
+ target: string;
+ agent: AgentKind;
+ mode: TermMode;
+ cwd: string;
+ session?: string; // attach to an existing persistent session
+}
+
+// Build the authed WS url for a pane
+export function buildWsUrl(creds: TermCreds, spec: LaunchSpec): string {
+ const p = new URLSearchParams();
+ p.set("target", spec.target);
+ p.set("agent", spec.agent);
+ p.set("mode", spec.mode);
+ if (spec.cwd) p.set("cwd", spec.cwd);
+ if (spec.session) p.set("session", spec.session);
+ p.set("token", creds.token);
+ return `ws://${creds.host}/ws?${p.toString()}`;
+}
face/src/lib/types.ts +100 -0
@@ -0,0 +1,100 @@
+// Darwin Face - Shared TypeScript Types
+// Contracts match the Darwin Cortex gateway API
+
+export interface Receipt {
+ id: string;
+ source: string;
+ path: string;
+ last_seen: string;
+ confidence: number;
+ snippet: string;
+}
+
+export interface Hit {
+ engine: string;
+ title: string;
+ path: string;
+ snippet: string;
+ score: number;
+ last_seen: string;
+ confidence: number;
+}
+
+export interface Conflict {
+ claim: string;
+ winner: string;
+ losers: string[];
+ reason: string;
+}
+
+export interface EngineStatus {
+ engine: string;
+ status: "up" | "down";
+ ms?: number;
+}
+
+export interface AskResponse {
+ answer: string | null;
+ receipts: Receipt[];
+ hits: Hit[];
+ conflicts: Conflict[];
+ engines: EngineStatus[];
+ took_ms: number;
+}
+
+export interface HealthEngine {
+ engine: string;
+ status: "up" | "down";
+ last_ingest: string;
+ staleness_h: number;
+}
+
+export interface HealthResponse {
+ engines: HealthEngine[];
+ ok: boolean;
+}
+
+// Organism node types
+export type DataSource =
+ | "proxmox"
+ | "white-pc"
+ | "3060"
+ | "browser-history"
+ | "agents"
+ | "rag"
+ | "cbm"
+ | "serena"
+ | "memgraph"
+ | "inbox";
+
+export interface OrganismNode {
+ id: DataSource;
+ label: string;
+ position: [number, number, number];
+ health: "fresh" | "stale" | "dead";
+ staleness_h: number;
+ connections: DataSource[];
+}
+
+// Agent tree types
+export type AgentStatus = "active" | "idle" | "error";
+export type AgentType = "root" | "claude" | "codex" | "glm" | "scheduler" | "probe";
+
+export interface AgentNode {
+ id: string;
+ label: string;
+ type: AgentType;
+ status: AgentStatus;
+ parentId: string | null;
+ lastActivity: string;
+ taskCount?: number;
+}
+
+// Event for organism context
+export interface OrganismContextValue {
+ healthData: HealthResponse | null;
+ focusSource: DataSource | null;
+ setFocusSource: (source: DataSource | null) => void;
+ isAsking: boolean;
+ setIsAsking: (v: boolean) => void;
+}
face/tailwind.config.ts +75 -0
@@ -0,0 +1,75 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: [
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ canvas: "#050607",
+ teal: {
+ bio: "#16e0c8",
+ dim: "#0d9e8e",
+ muted: "#0a6b60",
+ },
+ surface: {
+ 0: "#050607",
+ 1: "#0a0c0e",
+ 2: "#0f1214",
+ 3: "#141820",
+ },
+ text: {
+ primary: "#e8eaed",
+ secondary: "#8b929e",
+ muted: "#4a525e",
+ accent: "#16e0c8",
+ },
+ },
+ fontFamily: {
+ display: ["Space Grotesk", "sans-serif"],
+ ui: ["Inter", "sans-serif"],
+ mono: ["JetBrains Mono", "monospace"],
+ },
+ fontSize: {
+ "display-xl": ["clamp(3rem, 8vw, 7rem)", { lineHeight: "0.95", letterSpacing: "-0.04em" }],
+ "display-lg": ["clamp(2rem, 5vw, 4.5rem)", { lineHeight: "1.0", letterSpacing: "-0.03em" }],
+ "display-md": ["clamp(1.5rem, 3vw, 2.5rem)", { lineHeight: "1.1", letterSpacing: "-0.02em" }],
+ "label-sm": ["0.6875rem", { lineHeight: "1.2", letterSpacing: "0.08em" }],
+ "label-md": ["0.75rem", { lineHeight: "1.3", letterSpacing: "0.06em" }],
+ },
+ spacing: {
+ "18": "4.5rem",
+ "22": "5.5rem",
+ },
+ animation: {
+ "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
+ "glow-breathe": "glow-breathe 4s ease-in-out infinite",
+ "synapse-fire": "synapse-fire 0.6s ease-out forwards",
+ },
+ keyframes: {
+ "glow-breathe": {
+ "0%, 100%": { opacity: "0.6", transform: "scale(1)" },
+ "50%": { opacity: "1", transform: "scale(1.05)" },
+ },
+ "synapse-fire": {
+ "0%": { opacity: "0", transform: "scale(0.8)" },
+ "60%": { opacity: "1", transform: "scale(1.05)" },
+ "100%": { opacity: "1", transform: "scale(1)" },
+ },
+ },
+ transitionTimingFunction: {
+ "expo-out": "cubic-bezier(0.16, 1, 0.3, 1)",
+ "expo-inout": "cubic-bezier(0.87, 0, 0.13, 1)",
+ },
+ backdropBlur: {
+ xs: "2px",
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
face/tsconfig.json +26 -0
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
ingest/COLLECTORS.md +52 -0
@@ -0,0 +1,52 @@
+# Darwin - Ingestion Collectors (gap build)
+
+Reuse homelab-rag's change-only manifest engine verbatim. State manifest
+`/var/tmp/homelab-rag/state.sqlite` (local ext4). Registry
+`/shared/projects/homelab-rag/config/sources.yaml`. Driver `ops/ingest-all.sh`. Each collector below
+is a NEW source entry + a small change-only shipper using the same delta logic
+(mtime/size + tombstones, key = `source_id + normalized_path`). NO new index engine.
+
+Order: COVERED = reuse, BUILD = net-new (below). Confirmed gaps from reuse-map recon.
+
+## BUILD-1 - White PC files (re-enable, narrow root)
+- Source `desktop-qi823gr` (100.76.195.52) is disabled; only `C:\homelab` was scoped (absent).
+- Re-enable with narrow roots: `C:\Users\zion\Documents`, `C:\Users\zion\Desktop`,
+ `C:\Users\zion\Downloads` (skip the slow full tree). Same `ssh-powershell -EncodedCommand` collector.
+- Tailscale path; expect slower walks - keep roots tight, `timeout 150` per run.
+
+## BUILD-2 - Browser history (White, 3060, Linux CTs)
+The history DB **is** the index - ship it, don't re-crawl pages.
+- Chrome/Edge: `%LOCALAPPDATA%\<Browser>\User Data\Default\History` (SQLite; tables `urls`, `visits`).
+- Firefox: `%APPDATA%\Mozilla\Firefox\Profiles\*\places.sqlite`.
+- Linux CTs: `~/.config/google-chrome/Default/History`, `~/.mozilla/firefox/*/places.sqlite`.
+- The live DB is **locked while the browser runs** → collector copies it to a temp path first, then
+ reads rows with `last_visit_time` newer than the manifest's last watermark; ships the delta as
+ normalized records `{url, title, visit_time, source}`.
+
+## BUILD-3 - PowerShell history (White, 3060)
+- Single file `%APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt`.
+- Append-only → ship by byte-offset / line-count delta stored in the manifest. Trivial.
+
+## BUILD-4 - Linux shell history (per CT)
+- `~/.bash_history` / `~/.zsh_history`. History flushes on shell exit, so set
+ `PROMPT_COMMAND='history -a'` (login shells) to flush live; then the existing Linux SSH collector
+ sees the changed mtime each run. Add an explicit per-CT history shipper (line-count delta) rather
+ than relying on incidental `/home/zion` capture.
+
+## BUILD-5 - Clipboard history (the only resident-daemon gap)
+- No native store. Needs a small always-on watcher:
+ - Windows: a clipboard watcher appending timestamped entries to a JSONL.
+ - Linux: `greenclip`/`clipnotify` → JSONL.
+- Then ship that JSONL append-only via the same delta mechanism. This is the only collector that
+ needs a resident agent, not just a timer shipper.
+
+## PRIVACY GATE (hard)
+BUILD-1/2/3/5 on the **personal White desktop** capture browsing + clipboard + keystrokes-as-saved.
+That is privacy-sensitive and outward-affecting. **Staged, not auto-deployed.** Deploy only on Zion's
+explicit per-source approval. Homelab/Proxmox + the 3060 *service* box proceed freely; the personal
+White box waits for the word. Provide a per-source on/off so Zion green-lights exactly what he wants.
+
+## Acceptance
+- Each new source appears in `sources.yaml`, ships only changed rows (proven by a run record showing
+ `changed << seen`), tombstones deletions, and never re-indexes `/shared/projects` (dedup contract).
+- Darwin's Cortex `inbox`/`history` adapters can query the resulting chunks via homelab-rag :9876.
ingest/clipboard-daemon-linux.sh +54 -0
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Darwin BUILD-5 (STAGED) - Linux Clipboard History Daemon
+# Uses xclip/xsel or wl-clipboard (Wayland) to poll clipboard changes
+# and append to a JSONL file for homelab-rag ingestion.
+#
+# STATUS: STAGED - not yet deployed. Linux CTs (CT215/241) are headless
+# server containers with no display server, so clipboard capture
+# is not applicable there. If Zion runs a Linux desktop, deploy
+# this on that machine.
+#
+# INSTALL (on a Linux desktop with X11 or Wayland):
+# 1. Install xclip: apt install xclip (X11) or wl-clipboard (Wayland)
+# 2. Copy to ~/.local/bin/darwin-clipboard-daemon.sh and chmod +x
+# 3. Add to ~/.config/autostart/ or systemd user service
+# 4. Set OUTPUT_PATH to a location indexed by homelab-rag
+#
+# OUTPUT: ~/darwin-clipboard-history.jsonl (append-only)
+
+set -euo pipefail
+
+OUTPUT_PATH="${DARWIN_CLIPBOARD_OUTPUT:-$HOME/darwin-clipboard-history.jsonl}"
+POLL_INTERVAL="${DARWIN_CLIPBOARD_POLL:-0.5}"
+MAX_BYTES=65536
+
+# Detect clipboard tool
+if command -v wl-paste &>/dev/null && [ -n "${WAYLAND_DISPLAY:-}" ]; then
+ CLIP_CMD="wl-paste --no-newline 2>/dev/null"
+elif command -v xclip &>/dev/null && [ -n "${DISPLAY:-}" ]; then
+ CLIP_CMD="xclip -o -selection clipboard 2>/dev/null"
+elif command -v xsel &>/dev/null && [ -n "${DISPLAY:-}" ]; then
+ CLIP_CMD="xsel --clipboard --output 2>/dev/null"
+else
+ echo "[clipboard-daemon] No display or clipboard tool found. Headless CT? Exiting." >&2
+ exit 1
+fi
+
+last=""
+echo "[clipboard-daemon] Started. Writing to $OUTPUT_PATH"
+
+while true; do
+ current=$(eval "$CLIP_CMD" || echo "")
+ if [ -n "$current" ] && [ "$current" != "$last" ]; then
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+ byte_len=${#current}
+ if [ "$byte_len" -gt "$MAX_BYTES" ]; then
+ current="${current:0:$MAX_BYTES}...[TRUNCATED]"
+ fi
+ # JSON-escape: replace backslash, double-quote, newline, tab
+ escaped=$(printf '%s' "$current" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo '""')
+ printf '{"ts":"%s","content":%s,"len":%d}\n' "$ts" "$escaped" "$byte_len" >> "$OUTPUT_PATH"
+ last="$current"
+ fi
+ sleep "$POLL_INTERVAL"
+done
ingest/clipboard-daemon-windows.ps1 +65 -0
@@ -0,0 +1,65 @@
+# Darwin BUILD-5 (STAGED) - Windows Clipboard History Daemon
+# Runs as a background PowerShell process on Windows boxes.
+# Watches the clipboard via Add-Type C# event loop and appends timestamped
+# entries to a JSONL file. The homelab-rag ssh-powershell collector then
+# picks up the JSONL file via normal mtime/size delta (append-only).
+#
+# INSTALL (manual, run once on the Windows box as the logged-in user):
+# 1. Copy this script to C:\homelab\darwin\clipboard-daemon.ps1
+# 2. Create a scheduled task or startup shortcut:
+# powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\homelab\darwin\clipboard-daemon.ps1"
+# 3. Verify the JSONL appears at $OutputPath after a clipboard copy.
+#
+# OUTPUT: C:\homelab\darwin\clipboard-history.jsonl (append-only)
+# FORMAT: {"ts":"2026-06-24T01:23:45Z","content":"<clipboard text>","len":42}
+#
+# STATUS: STAGED - not yet deployed. Deploy after verifying daemon stability
+# on a test session. The output path is already in C:\homelab which
+# is indexed by desktop-f8o1v2s source. No additional source entry needed.
+
+param(
+ [string]$OutputPath = "C:\homelab\darwin\clipboard-history.jsonl",
+ [int]$PollIntervalMs = 500,
+ [int]$MaxContentBytes = 65536
+)
+
+Add-Type -AssemblyName System.Windows.Forms
+
+# Ensure output directory exists
+$null = [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($OutputPath))
+
+$lastClipboard = ""
+$epoch = [datetime]'1970-01-01T00:00:00Z'
+
+function Get-SafeClipboard {
+ try {
+ return [System.Windows.Forms.Clipboard]::GetText()
+ } catch {
+ return $null
+ }
+}
+
+function Append-Record([string]$content) {
+ $ts = [datetime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
+ # Truncate if over size limit
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($content)
+ if ($bytes.Length -gt $MaxContentBytes) {
+ $content = [System.Text.Encoding]::UTF8.GetString($bytes, 0, $MaxContentBytes) + "...[TRUNCATED]"
+ }
+ # Escape for JSON
+ $escaped = $content -replace '\\', '\\' -replace '"', '\"' -replace "`n", '\n' -replace "`r", '\r' -replace "`t", '\t'
+ $record = '{"ts":"' + $ts + '","content":"' + $escaped + '","len":' + $bytes.Length + '}'
+ Add-Content -Path $OutputPath -Value $record -Encoding UTF8
+}
+
+Write-Host "[clipboard-daemon] Started. Watching clipboard -> $OutputPath"
+Write-Host "[clipboard-daemon] Press Ctrl+C to stop."
+
+while ($true) {
+ $current = Get-SafeClipboard
+ if ($null -ne $current -and $current -ne "" -and $current -ne $lastClipboard) {
+ Append-Record $current
+ $lastClipboard = $current
+ }
+ Start-Sleep -Milliseconds $PollIntervalMs
+}
ingest/collect-browser-history.py +328 -0
@@ -0,0 +1,328 @@
+#!/usr/bin/env python3
+"""
+Darwin BUILD-5 - Browser History Collector (standalone, invoked by ingest-all.sh)
+
+Reads browser SQLite history DBs via Python-over-SSH.
+Uses python3 (available on Windows via Git/standalone install) to:
+ 1. Copy the live DB to a temp path (readable even when browser is open).
+ 2. Query rows newer than the manifest watermark (stored in browser-watermarks.json).
+ 3. Emit normalized JSON records to stdout for downstream indexing.
+
+Browsers supported:
+ edge, chrome, brave - Chromium History SQLite (tables: urls, visits)
+ firefox - places.sqlite (tables: moz_places, moz_historyvisits)
+
+Usage:
+ python3 collect-browser-history.py --host HOST --user SSH_USER --browser BROWSER
+ # If the Windows profile username differs from SSH user:
+ python3 collect-browser-history.py --host HOST --user zion --profile-user worky --browser brave
+
+Browser path map (Windows):
+ Edge: C:\\\\Users\\\\<user>\\\\AppData\\\\Local\\\\Microsoft\\\\Edge\\\\User Data\\\\Default\\\\History
+ Chrome: C:\\\\Users\\\\<user>\\\\AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data\\\\Default\\\\History
+ Brave: C:\\\\Users\\\\<user>\\\\AppData\\\\Local\\\\BraveSoftware\\\\Brave-Browser\\\\User Data\\\\Default\\\\History
+ Firefox: C:\\\\Users\\\\<user>\\\\AppData\\\\Roaming\\\\Mozilla\\\\Firefox\\\\Profiles\\\\<profile>\\\\places.sqlite
+"""
+
+import argparse
+import base64
+import json
+import os
+import subprocess
+import sys
+import time
+
+WATERMARK_PATH = "/var/tmp/homelab-rag/browser-watermarks.json"
+OUTPUT_DIR = "/var/tmp/homelab-rag/browser-history"
+
+
+def load_watermarks():
+ if os.path.exists(WATERMARK_PATH):
+ with open(WATERMARK_PATH) as f:
+ return json.load(f)
+ return {}
+
+
+def save_watermarks(wm):
+ os.makedirs(os.path.dirname(WATERMARK_PATH), exist_ok=True)
+ with open(WATERMARK_PATH, "w") as f:
+ json.dump(wm, f, indent=2)
+
+
+def run_python_via_ssh(host, ssh_user, py_script, connect_timeout=30, identity=None):
+ """Run a Python script on a remote Windows host via SSH + python -c (stdin deploy).
+
+ The script is sent via stdin to avoid command-line length limits.
+ """
+ target = f"{ssh_user}@{host}" if ssh_user else host
+ ssh_args = [
+ "ssh",
+ "-o", "BatchMode=yes",
+ "-o", f"ConnectTimeout={connect_timeout}",
+ "-o", "StrictHostKeyChecking=accept-new",
+ ]
+ if identity:
+ ssh_args += ["-i", identity]
+ # Deploy script via stdin, invoke python which reads from stdin
+ ssh_args += [target, "python -"]
+ result = subprocess.run(
+ ssh_args,
+ input=py_script.encode("utf-8"),
+ capture_output=True,
+ timeout=120,
+ )
+ return result
+
+
+# Python script template for Chromium-based browsers (Edge, Chrome, Brave).
+# Injected payload replaces PAYLOAD_JSON placeholder.
+# Chromium stores visit_time as microseconds since 1601-01-01 (Windows FILETIME).
+CHROMIUM_PY_TEMPLATE = r"""
+import sqlite3, json, shutil, tempfile, os, sys
+
+payload = PAYLOAD_JSON
+hist_path = payload["history_path"]
+watermark_ms = int(payload["watermark_ms"])
+source_id = payload["source_id"]
+
+# Copy locked DB to temp (read-share works even with browser open)
+tmp = tempfile.mktemp(suffix=".db")
+try:
+ shutil.copy2(hist_path, tmp)
+except Exception as e:
+ print(json.dumps({"error": "copy_failed", "msg": str(e)}))
+ sys.exit(0)
+
+try:
+ CHROMIUM_EPOCH_MS = 11644473600000 # ms between unix epoch and 1601-01-01
+ watermark_chromium = (watermark_ms + CHROMIUM_EPOCH_MS) * 1000 # to microseconds since 1601
+
+ conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
+ conn.row_factory = sqlite3.Row
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT u.url, u.title, v.visit_time "
+ "FROM visits v JOIN urls u ON u.id=v.url "
+ "WHERE v.visit_time > ? "
+ "ORDER BY v.visit_time DESC LIMIT 50000",
+ (watermark_chromium,)
+ )
+ records = []
+ for row in cur.fetchall():
+ chromium_us = int(row[2])
+ unix_ms = chromium_us // 1000 - CHROMIUM_EPOCH_MS
+ records.append({
+ "url": row[0] or "",
+ "title": row[1] or "",
+ "visit_time_ms": unix_ms,
+ })
+ conn.close()
+ print(json.dumps(records))
+except Exception as e:
+ print(json.dumps({"error": "sqlite_read_failed", "msg": str(e)}))
+finally:
+ try:
+ os.unlink(tmp)
+ except Exception:
+ pass
+"""
+
+# Python script template for Firefox places.sqlite.
+# Firefox stores visit_date as microseconds since Unix epoch (no offset needed).
+FIREFOX_PY_TEMPLATE = r"""
+import sqlite3, json, shutil, tempfile, os, sys
+
+payload = PAYLOAD_JSON
+db_path = payload["history_path"]
+watermark_ms = int(payload["watermark_ms"])
+source_id = payload["source_id"]
+
+tmp = tempfile.mktemp(suffix=".sqlite")
+try:
+ shutil.copy2(db_path, tmp)
+except Exception as e:
+ print(json.dumps({"error": "copy_failed", "msg": str(e)}))
+ sys.exit(0)
+
+try:
+ watermark_us = watermark_ms * 1000 # convert ms to microseconds
+
+ conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
+ conn.row_factory = sqlite3.Row
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT p.url, p.title, h.visit_date "
+ "FROM moz_historyvisits h JOIN moz_places p ON p.id=h.place_id "
+ "WHERE h.visit_date > ? "
+ "ORDER BY h.visit_date DESC LIMIT 50000",
+ (watermark_us,)
+ )
+ records = []
+ for row in cur.fetchall():
+ visit_us = int(row[2])
+ unix_ms = visit_us // 1000
+ records.append({
+ "url": row[0] or "",
+ "title": row[1] or "",
+ "visit_time_ms": unix_ms,
+ })
+ conn.close()
+ print(json.dumps(records))
+except Exception as e:
+ print(json.dumps({"error": "sqlite_read_failed", "msg": str(e)}))
+finally:
+ try:
+ os.unlink(tmp)
+ except Exception:
+ pass
+"""
+
+
+def collect_chromium(host, ssh_user, history_path, source_key, watermarks, identity=None):
+ """Collect Chromium-based browser history via Python-over-SSH."""
+ wm_ms = watermarks.get(source_key, 0)
+ payload = json.dumps({
+ "history_path": history_path,
+ "watermark_ms": wm_ms,
+ "source_id": source_key,
+ })
+ script = CHROMIUM_PY_TEMPLATE.replace("PAYLOAD_JSON", payload)
+ result = run_python_via_ssh(host, ssh_user, script, identity=identity)
+
+ if result.returncode != 0:
+ print(f"[browser-history] SSH/python failed for {source_key}: {result.stderr[:300]}", file=sys.stderr)
+ return []
+
+ stdout = result.stdout.decode("utf-8", errors="replace").strip()
+ if not stdout:
+ print(f"[browser-history] Empty output for {source_key}", file=sys.stderr)
+ return []
+
+ try:
+ rows = json.loads(stdout)
+ except json.JSONDecodeError as e:
+ print(f"[browser-history] JSON parse error for {source_key}: {e} | raw: {stdout[:200]}", file=sys.stderr)
+ return []
+
+ if isinstance(rows, dict) and "error" in rows:
+ print(f"[browser-history] Remote error for {source_key}: {rows}", file=sys.stderr)
+ return []
+
+ return _process_rows(rows, source_key, wm_ms, watermarks)
+
+
+def collect_firefox(host, ssh_user, profile_path, source_key, watermarks, identity=None):
+ """Collect Firefox places.sqlite via Python-over-SSH."""
+ wm_ms = watermarks.get(source_key, 0)
+ payload = json.dumps({
+ "history_path": profile_path,
+ "watermark_ms": wm_ms,
+ "source_id": source_key,
+ })
+ script = FIREFOX_PY_TEMPLATE.replace("PAYLOAD_JSON", payload)
+ result = run_python_via_ssh(host, ssh_user, script, identity=identity)
+
+ if result.returncode != 0:
+ print(f"[browser-history] SSH/python failed for {source_key}: {result.stderr[:300]}", file=sys.stderr)
+ return []
+
+ stdout = result.stdout.decode("utf-8", errors="replace").strip()
+ if not stdout:
+ print(f"[browser-history] Empty output for {source_key}", file=sys.stderr)
+ return []
+
+ try:
+ rows = json.loads(stdout)
+ except json.JSONDecodeError as e:
+ print(f"[browser-history] JSON parse error for {source_key}: {e} | raw: {stdout[:200]}", file=sys.stderr)
+ return []
+
+ if isinstance(rows, dict) and "error" in rows:
+ print(f"[browser-history] Remote error for {source_key}: {rows}", file=sys.stderr)
+ return []
+
+ return _process_rows(rows, source_key, wm_ms, watermarks)
+
+
+def _process_rows(rows, source_key, wm_ms, watermarks):
+ """Normalize rows and update watermark."""
+ records = []
+ max_ms = wm_ms
+ for row in rows:
+ if isinstance(row, dict) and "url" in row:
+ visit_ms = int(row.get("visit_time_ms", 0))
+ if visit_ms > max_ms:
+ max_ms = visit_ms
+ records.append({
+ "source_id": source_key,
+ "url": row["url"],
+ "title": row.get("title", ""),
+ "visit_time_ms": visit_ms,
+ "collected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ })
+ if max_ms > wm_ms:
+ watermarks[source_key] = max_ms
+ return records
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Darwin browser history collector")
+ parser.add_argument("--host", required=True)
+ parser.add_argument("--user", required=True, help="SSH login user")
+ parser.add_argument("--profile-user", default=None,
+ help="Windows profile username (if different from SSH user, e.g. 'worky' vs ssh user 'zion')")
+ parser.add_argument("--browser", required=True, choices=["edge", "chrome", "brave", "firefox"])
+ parser.add_argument("--firefox-profile", default=None,
+ help="Firefox profile dir name (e.g. ojo1adxx.default-release)")
+ parser.add_argument("--source-key", default=None,
+ help="Override source key (default: <host>-<profile_user>-<browser>)")
+ parser.add_argument("--identity", default=os.path.expanduser("~/.ssh/termius_homelab"))
+ parser.add_argument("--output", default=None, help="Output JSONL path (default: auto)")
+ args = parser.parse_args()
+
+ profile_user = args.profile_user or args.user
+ source_key = args.source_key or f"{args.host}-{profile_user}-{args.browser}"
+
+ watermarks = load_watermarks()
+
+ user_dir = f"C:\\Users\\{profile_user}"
+ browser_paths = {
+ "edge": f"{user_dir}\\AppData\\Local\\Microsoft\\Edge\\User Data\\Default\\History",
+ "chrome": f"{user_dir}\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\History",
+ "brave": f"{user_dir}\\AppData\\Local\\BraveSoftware\\Brave-Browser\\User Data\\Default\\History",
+ }
+
+ if args.browser in ("edge", "chrome", "brave"):
+ history_path = browser_paths[args.browser]
+ records = collect_chromium(
+ args.host, args.user, history_path, source_key, watermarks,
+ identity=args.identity
+ )
+ elif args.browser == "firefox":
+ if args.firefox_profile:
+ places_path = f"{user_dir}\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\{args.firefox_profile}\\places.sqlite"
+ else:
+ # Default known profile for worky on White PC
+ places_path = f"{user_dir}\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\ojo1adxx.default-release\\places.sqlite"
+ records = collect_firefox(
+ args.host, args.user, places_path, source_key, watermarks,
+ identity=args.identity
+ )
+ else:
+ print(f"[browser-history] Unsupported browser: {args.browser}", file=sys.stderr)
+ records = []
+
+ if records:
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
+ out_path = args.output or os.path.join(OUTPUT_DIR, f"{source_key}.jsonl")
+ with open(out_path, "a", encoding="utf-8") as f:
+ for rec in records:
+ f.write(json.dumps(rec) + "\n")
+ print(f"[browser-history] Wrote {len(records)} records to {out_path}", file=sys.stderr)
+ save_watermarks(watermarks)
+ else:
+ print(f"[browser-history] No new records for {source_key}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
ingest/setup-bash-history-flush.sh +62 -0
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Darwin BUILD-4 - enable live bash_history flushing on Linux CTs
+#
+# The homelab-rag change-only engine detects mtime changes. bash_history
+# only flushes on shell EXIT by default, so mid-session history is invisible.
+# Setting PROMPT_COMMAND='history -a' in /etc/profile.d/ makes every new
+# prompt flush the in-memory history ring to disk - the file mtime updates
+# each prompt, and the next ingest cycle picks up the delta.
+#
+# Run once per CT from CT215. Safe to re-run (idempotent).
+#
+# Targets: CT215 (localhost), CT241
+
+set -Eeuo pipefail
+
+FLUSH_SNIPPET='# Darwin: flush bash history after every command
+export HISTFILE="${HISTFILE:-$HOME/.bash_history}"
+export HISTFILESIZE=100000
+export HISTSIZE=10000
+PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }history -a"'
+
+install_on_ct() {
+ local host="$1"
+ local label="$2"
+ echo "=== Installing history flush on $label ($host) ==="
+
+ if [ "$host" = "localhost" ]; then
+ # CT215 - run directly
+ local dest="/etc/profile.d/darwin-history-flush.sh"
+ if [ -f "$dest" ]; then
+ echo " Already installed at $dest - skipping"
+ else
+ printf '%s\n' "$FLUSH_SNIPPET" > "$dest"
+ chmod 644 "$dest"
+ echo " Installed at $dest"
+ fi
+ else
+ ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new \
+ root@"$host" bash -s <<'REMOTE'
+FLUSH_SNIPPET='# Darwin: flush bash history after every command
+export HISTFILE="${HISTFILE:-$HOME/.bash_history}"
+export HISTFILESIZE=100000
+export HISTSIZE=10000
+PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }history -a"'
+dest="/etc/profile.d/darwin-history-flush.sh"
+if [ -f "$dest" ]; then
+ echo " Already installed at $dest - skipping"
+else
+ printf '%s\n' "$FLUSH_SNIPPET" > "$dest"
+ chmod 644 "$dest"
+ echo " Installed at $dest"
+fi
+REMOTE
+ fi
+}
+
+install_on_ct "localhost" "CT215"
+install_on_ct "REDACTED-IP" "CT241"
+
+echo ""
+echo "Done. New login shells on each CT will now flush bash_history live."
+echo "Existing sessions: run 'source /etc/profile.d/darwin-history-flush.sh' to activate."
ingest/sync-white-pc.sh +223 -0
@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+# Darwin BUILD-5 - White PC (desktop-qi823gr*) manifest sync wrapper
+#
+# The homelab-rag ssh-powershell collector embeds the entire PowerShell manifest walker
+# as an -EncodedCommand. The White PC's OpenSSH server has an ~8192-char remote command
+# limit that all desktop-qi823gr* sources exceed.
+#
+# Workaround: pre-deploy the walker .ps1 via SSH stdin (no command-line limit), then
+# invoke it with a short EncodedCommand per source. Contract with sqlite_bridge is
+# identical to the standard transport (same record schema, same sync action).
+#
+# Sources handled (all on 100.76.195.52, SSH user: zion):
+# desktop-qi823gr - zion user Documents + Desktop
+# desktop-qi823gr-worky - worky user Documents + Desktop + Downloads + PSReadLine
+# desktop-qi823gr-browser-brave - worky Brave history
+# desktop-qi823gr-browser-edge - worky Edge history
+# desktop-qi823gr-browser-firefox - worky Firefox places
+#
+# Usage: bash sync-white-pc.sh [--dry-run]
+# Called from ingest-all.sh step 2c.
+
+set -Eeuo pipefail
+
+STATE_DB="${HOMELAB_RAG_STATE_DB:-/var/tmp/homelab-rag/state.sqlite}"
+PROJECT_DIR="${HOMELAB_RAG_PROJECT_DIR:-/shared/projects/homelab-rag}"
+SSH_KEY="${HOME:-/root}/.ssh/termius_homelab"
+HOST="100.76.195.52"
+SSH_USER="zion"
+WALKER_REMOTE_WIN='C:\Users\zion\AppData\Local\Temp\homelab-rag-walker.ps1'
+WALKER_REMOTE_PS='C:\\Users\\zion\\AppData\\Local\\Temp\\homelab-rag-walker.ps1'
+
+DRY_RUN=0
+for arg in "$@"; do [ "$arg" = "--dry-run" ] && DRY_RUN=1; done
+
+log() { printf '[%s] %s\n' "$(date -Is)" "$*" >&2; }
+
+SSH_BASE="ssh -o BatchMode=yes -o ConnectTimeout=12 -o StrictHostKeyChecking=accept-new -i ${SSH_KEY} ${SSH_USER}@${HOST}"
+
+# ── Walker script (compact, correct PS syntax) ───────────────────────────────
+WALKER_LOCAL=$(mktemp /tmp/homelab-rag-walker-XXXXXX.ps1)
+trap 'rm -f "$WALKER_LOCAL"' EXIT
+
+cat > "$WALKER_LOCAL" <<'WALKER_BODY'
+param([string]$PayloadJson)
+$p = $PayloadJson | ConvertFrom-Json
+$sid = [string]$p.source_id
+$roots = @($p.roots)
+$excl = @($p.exclude | ForEach-Object { ([string]$_).Replace('\','/').ToLowerInvariant() })
+$maxD = [int]$p.max_depth
+$maxF = [int]$p.max_files
+$maxB = [int64]$p.max_file_bytes
+$script:recs = New-Object System.Collections.Generic.List[object]
+$script:st = [ordered]@{ filesSeen=0; skipped=0; errors=0; truncated=$false }
+$ep = [datetime]'1970-01-01T00:00:00Z'
+function cslash([string]$v) { $v.Replace('\','/') }
+function is_excl([string]$path, [string]$name) {
+ $n = (cslash $path).ToLowerInvariant(); $l = $name.ToLowerInvariant()
+ foreach ($x in $excl) { if ($l -eq $x -or $n.EndsWith('/'+$x) -or $n.Contains('/'+$x+'/')) { return $true } }
+ return $false
+}
+function kh([string]$path) {
+ $l = $path.ToLowerInvariant()
+ if ($l -match '\.(md|txt|rst|docx|pdf|pptx)$') { return 'doc' }
+ if ($l -match '\.(json|ya?ml|toml|ini|conf|env|service|ps1)$') { return 'config' }
+ if ($l -match '\.(log|out|err)$') { return 'log' }
+ if ($l -match '\.(js|ts|py|sh|go|rs|html|css)$') { return 'code' }
+ return 'other'
+}
+function walk([string]$root, [int]$depth) {
+ if ($script:st['truncated']) { return }
+ if ($maxD -ge 0 -and $depth -gt $maxD) { return }
+ try { $entries = Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop } catch { $script:st['errors'] += 1; return }
+ foreach ($entry in $entries) {
+ if ($script:st['truncated']) { return }
+ if (is_excl $entry.FullName $entry.Name) { continue }
+ if ($entry.PSIsContainer) { walk ([string]$entry.FullName) ($depth + 1); continue }
+ try {
+ $sz = [int64]$entry.Length
+ $mt = [int64](($entry.LastWriteTimeUtc - $ep).TotalMilliseconds)
+ $fp = cslash $entry.FullName
+ $sk = if ($sz -gt $maxB) { 'large>' + $maxB } else { '' }
+ if ($sk) { $script:st['skipped'] += 1 } else { $script:st['filesSeen'] += 1 }
+ $script:recs.Add([ordered]@{
+ source_id = $sid; path = $fp; normalized_path = $fp
+ size = $sz; mtime_ms = $mt; kind_hint = (kh $entry.FullName)
+ seen_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ skip_reason = $sk
+ }) | Out-Null
+ if ($maxF -gt 0 -and $script:recs.Count -ge $maxF) { $script:st['truncated'] = $true; return }
+ } catch { $script:st['errors'] += 1 }
+ }
+}
+foreach ($r in $roots) { walk ([string]$r) 0 }
+$script:st['records'] = $script:recs.ToArray()
+$script:st | ConvertTo-Json -Depth 8 -Compress
+WALKER_BODY
+
+log "Walker size: $(wc -c < "$WALKER_LOCAL") bytes"
+
+if [ "$DRY_RUN" -eq 1 ]; then
+ log "[DRY-RUN] Would deploy walker to $HOST and run sync for all desktop-qi823gr* sources"
+ exit 0
+fi
+
+# ── Step 1: Deploy walker via stdin (sidesteps the 8192-char command limit) ──
+log "Deploying walker to $HOST:$WALKER_REMOTE_WIN"
+DEPLOY_RESULT=$(cat "$WALKER_LOCAL" | $SSH_BASE \
+ "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"\$content=[Console]::In.ReadToEnd(); [System.IO.File]::WriteAllText('$WALKER_REMOTE_WIN',\$content,[System.Text.Encoding]::UTF8); Write-Output 'DEPLOYED_OK'\"" 2>&1 || true)
+if ! echo "$DEPLOY_RESULT" | grep -q "DEPLOYED_OK"; then
+ log "ERROR: Deploy failed. Output: ${DEPLOY_RESULT:0:300}"
+ exit 1
+fi
+log "Walker deployed OK"
+
+# ── Helper: run one source via the deployed walker ────────────────────────────
+run_source() {
+ local SOURCE_ID="$1"
+ local PAYLOAD="$2"
+ local SOURCE_META="$3" # JSON for sqlite_bridge source object
+
+ log " Running walker for $SOURCE_ID"
+ local PAYLOAD_PS="${PAYLOAD//\'/\'\'}"
+ local RUN_PS=". '$WALKER_REMOTE_PS' '$PAYLOAD_PS'"
+ local RUN_ENC
+ RUN_ENC=$(python3 -c "import base64,sys; print(base64.b64encode(sys.argv[1].encode('utf-16-le')).decode())" "$RUN_PS")
+ log " EncodedCommand length: ${#RUN_ENC}"
+
+ local MANIFEST_RAW
+ MANIFEST_RAW=$($SSH_BASE "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $RUN_ENC" 2>&1 || true)
+
+ local MANIFEST_JSON
+ MANIFEST_JSON=$(python3 -c "
+import sys, re, json
+data = sys.stdin.read()
+for cand in re.findall(r'(\{[^\x00]{10,}\})', data, re.DOTALL):
+ try:
+ obj = json.loads(cand)
+ if 'filesSeen' in obj:
+ print(cand)
+ break
+ except Exception:
+ pass
+" <<< "$MANIFEST_RAW" 2>/dev/null || true)
+
+ if [ -z "$MANIFEST_JSON" ]; then
+ log " ERROR: No JSON in manifest output for $SOURCE_ID. Raw (first 400):"
+ echo "${MANIFEST_RAW:0:400}" >&2
+ return 1
+ fi
+
+ local FILES_SEEN
+ FILES_SEEN=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('filesSeen',0))" <<< "$MANIFEST_JSON" 2>/dev/null || echo "?")
+ log " Files seen: $FILES_SEEN"
+
+ local STARTED_AT
+ STARTED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+ local BRIDGE_OUT
+ BRIDGE_OUT=$(python3 - <<PYEOF
+import json, sys, subprocess, os
+
+state_db = "$STATE_DB"
+source_id = "$SOURCE_ID"
+host = "$HOST"
+started_at = "$STARTED_AT"
+project_dir = "$PROJECT_DIR"
+
+manifest = json.loads(r"""$MANIFEST_JSON""")
+records = manifest.get("records", [])
+errors = manifest.get("errors", 0)
+
+source_meta = json.loads(r"""$SOURCE_META""")
+
+payload = {"action":"sync","statePath":state_db,"source":source_meta,"startedAt":started_at,"records":records,"errors":errors}
+bridge = os.path.join(project_dir, "src", "sqlite_bridge.py")
+result = subprocess.run(["python3", bridge], input=json.dumps(payload).encode(), capture_output=True, timeout=60)
+if result.returncode != 0:
+ print("BRIDGE_ERROR:" + result.stderr.decode()[:300], file=sys.stderr)
+ sys.exit(1)
+print(result.stdout.decode().strip())
+PYEOF
+)
+ log " Bridge result for $SOURCE_ID: $BRIDGE_OUT"
+ local DELTA
+ DELTA=$(python3 -c "
+import json,sys
+d=json.loads(sys.argv[1])
+print('new=%d changed=%d unchanged=%d tombstoned=%d' % (d.get('newFiles',0),d.get('changedFiles',0),d.get('unchangedFiles',0),d.get('tombstonedFiles',0)))
+" "$BRIDGE_OUT" 2>/dev/null || echo "parse error")
+ log " Change-only delta ($SOURCE_ID): $DELTA"
+}
+
+# ── Source 1: desktop-qi823gr (zion user - original source) ──────────────────
+run_source \
+ "desktop-qi823gr" \
+ '{"source_id":"desktop-qi823gr","roots":["C:\\Users\\zion\\Documents","C:\\Users\\zion\\Desktop"],"exclude":["AppData","node_modules","cache",".git","Videos","Downloads"],"max_depth":4,"max_files":25000,"max_file_bytes":67108864}' \
+ '{"id":"desktop-qi823gr","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":6,"cadence":"2h","canonical":false,"enabled":true,"notes":"White PC over Tailscale. Darwin BUILD-1. SSH stdin deploy workaround.","roots":["C:\\\\Users\\\\zion\\\\Documents","C:\\\\Users\\\\zion\\\\Desktop"],"exclude":["AppData","node_modules","cache",".git","Videos","Downloads"],"maxDepth":4,"maxFiles":25000,"user":"zion"}'
+
+# ── Source 2: desktop-qi823gr-worky (worky user general + PSReadLine) ────────
+run_source \
+ "desktop-qi823gr-worky" \
+ '{"source_id":"desktop-qi823gr-worky","roots":["C:\\Users\\worky\\Documents","C:\\Users\\worky\\Desktop","C:\\Users\\worky\\Downloads","C:\\Users\\worky\\AppData\\Roaming\\Microsoft\\Windows\\PowerShell\\PSReadLine"],"exclude":["AppData","node_modules",".git","cache","Videos"],"max_depth":3,"max_files":10000,"max_file_bytes":67108864}' \
+ '{"id":"desktop-qi823gr-worky","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":8,"cadence":"1h","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. White PC worky user files + PSReadLine history.","roots":["C:\\\\Users\\\\worky\\\\Documents","C:\\\\Users\\\\worky\\\\Desktop","C:\\\\Users\\\\worky\\\\Downloads","C:\\\\Users\\\\worky\\\\AppData\\\\Roaming\\\\Microsoft\\\\Windows\\\\PowerShell\\\\PSReadLine"],"exclude":["AppData","node_modules",".git","cache","Videos"],"maxDepth":3,"maxFiles":10000,"user":"zion"}'
+
+# ── Source 3: desktop-qi823gr-browser-brave (worky Brave - 25.7MB, active daily) ──
+run_source \
+ "desktop-qi823gr-browser-brave" \
+ '{"source_id":"desktop-qi823gr-browser-brave","roots":["C:\\Users\\worky\\AppData\\Local\\BraveSoftware\\Brave-Browser\\User Data\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB","VideoDecodeStats","optimization_guide_hint_cache_store","optimization_guide_model_metadata_store","GCM Store","Sync Data","Extension Rules","Extension Scripts","Platform Notifications"],"max_depth":2,"max_files":5000,"max_file_bytes":67108864}' \
+ '{"id":"desktop-qi823gr-browser-brave","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":9,"cadence":"30m","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. Brave browser profile for White PC worky user. 25.7MB History, active daily.","roots":["C:\\\\Users\\\\worky\\\\AppData\\\\Local\\\\BraveSoftware\\\\Brave-Browser\\\\User Data\\\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB"],"maxDepth":2,"maxFiles":5000,"user":"zion"}'
+
+# ── Source 4: desktop-qi823gr-browser-edge (worky Edge - 288KB) ──────────────
+run_source \
+ "desktop-qi823gr-browser-edge" \
+ '{"source_id":"desktop-qi823gr-browser-edge","roots":["C:\\Users\\worky\\AppData\\Local\\Microsoft\\Edge\\User Data\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB","VideoDecodeStats","optimization_guide_hint_cache_store","optimization_guide_model_metadata_store","GCM Store","Sync Data","Extension Rules","Extension Scripts","Platform Notifications"],"max_depth":2,"max_files":5000,"max_file_bytes":67108864}' \
+ '{"id":"desktop-qi823gr-browser-edge","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":7,"cadence":"1h","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. Edge browser profile for White PC worky user. 288KB History.","roots":["C:\\\\Users\\\\worky\\\\AppData\\\\Local\\\\Microsoft\\\\Edge\\\\User Data\\\\Default"],"exclude":["Code Cache","GPUCache","ShaderCache","Service Worker","CacheStorage","Cache","cache","IndexedDB"],"maxDepth":2,"maxFiles":5000,"user":"zion"}'
+
+# ── Source 5: desktop-qi823gr-browser-firefox (worky Firefox - 5MB) ──────────
+run_source \
+ "desktop-qi823gr-browser-firefox" \
+ '{"source_id":"desktop-qi823gr-browser-firefox","roots":["C:\\Users\\worky\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\ojo1adxx.default-release"],"exclude":["cache2","startupCache","thumbnails","storage","datareporting","crashes","minidumps"],"max_depth":2,"max_files":3000,"max_file_bytes":67108864}' \
+ '{"id":"desktop-qi823gr-browser-firefox","host":"100.76.195.52","type":"windows-desktop","transport":"ssh-powershell","authority":9,"cadence":"30m","canonical":false,"enabled":true,"notes":"Darwin BUILD-5. Firefox places.sqlite for White PC worky user. 5MB, last modified 2026-06-15.","roots":["C:\\\\Users\\\\worky\\\\AppData\\\\Roaming\\\\Mozilla\\\\Firefox\\\\Profiles\\\\ojo1adxx.default-release"],"exclude":["cache2","startupCache","thumbnails","storage","datareporting","crashes","minidumps"],"maxDepth":2,"maxFiles":3000,"user":"zion"}'
+
+log "All desktop-qi823gr* sources complete"
rag_bridge.py +402 -0
@@ -0,0 +1,402 @@
+#!/usr/bin/env python3
+"""
+Darwin RAG bridge - port 9875.
+Fast HTTP service that searches homelab-rag index directly.
+
+Primary: SQLite FTS5 at /var/tmp/homelab-rag/state.sqlite (immutable read snapshot)
+Fallback: streaming keyword scan of /shared/projects/.homelab-rag/index.jsonl
+ (scans first STREAM_SCAN_BYTES ~80MB at ~0.4s on CIFS)
+
+Endpoints:
+ GET /search?q=QUERY&limit=N -> JSON array of results
+ GET /health -> {"ok": true, "sqlite": bool, "stream_fallback": bool}
+ GET /sources -> {"ok": bool, "now": iso, "sources":[{source_id,
+ host,type,last_ingest,staleness_h,files_seen}...]}
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import sqlite3
+import time
+from datetime import datetime, timezone
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs, urlparse
+
+STATE_DB = os.environ.get("RAG_STATE_DB", "/var/tmp/homelab-rag/state.sqlite")
+INDEX_JSONL = os.environ.get(
+ "RAG_INDEX_JSONL", "/shared/projects/.homelab-rag/index.jsonl"
+)
+PORT = int(os.environ.get("RAG_BRIDGE_PORT", "9875"))
+MAX_RESULTS = 20
+DEFAULT_LIMIT = 8
+
+# Streaming fallback: scan this many bytes of index.jsonl (~0.3s on CIFS)
+STREAM_SCAN_BYTES = 50 * 1024 * 1024 # 50 MB
+
+STOPWORDS = {
+ "the", "and", "for", "that", "this", "with", "from", "what", "where",
+ "how", "into", "about", "your", "you", "are", "is", "was", "has",
+ "have", "been", "not", "but", "can", "will", "would", "could", "should",
+ "of", "in", "on", "at", "to", "by", "an", "as", "it", "its", "or",
+ "all", "any", "be", "do", "up", "so", "my", "we", "us",
+}
+
+
+def _fts_terms(query: str) -> list[str]:
+ raw = re.findall(r"[A-Za-z0-9][A-Za-z0-9._:-]{1,}", query.lower())
+ return [t.replace('"', "") for t in raw if t not in STOPWORDS]
+
+
+def _fts_match(terms: list[str]) -> str:
+ if not terms:
+ return ""
+ return " OR ".join(f'"{t}"' for t in terms[:16])
+
+
+def _norm_score(rank: float, authority: int) -> float:
+ """Normalise bm25 rank (negative, closer to 0 = better) to 0..1."""
+ base = min(1.0, max(0.0, -rank / 15.0))
+ return round(min(1.0, base + authority * 0.02), 4)
+
+
+def _sqlite_search(query: str, limit: int) -> list[dict] | None:
+ """Try SQLite FTS. Returns None if DB is unavailable or FTS is corrupt."""
+ terms = _fts_terms(query)
+ match = _fts_match(terms)
+ if not match:
+ return []
+
+ # immutable=1 opens a read-only snapshot bypassing exclusive locks.
+ # This works when the rollback journal is in a consistent state.
+ # If the FTS B-tree is mid-rebuild the query will raise DatabaseError.
+ try:
+ con = sqlite3.connect(
+ f"file:{STATE_DB}?immutable=1",
+ uri=True,
+ timeout=1.5,
+ check_same_thread=False,
+ )
+ con.row_factory = sqlite3.Row
+ except sqlite3.OperationalError:
+ return None
+
+ try:
+ candidate_limit = max(limit * 10, 60)
+ rows = con.execute(
+ """
+ SELECT sc.id, sc.path, sc.rel_path AS relPath, sc.project, sc.kind,
+ sc.title, NULLIF(sc.symbol, '') AS symbol,
+ sc.start_line AS startLine, sc.end_line AS endLine,
+ sc.authority, sc.stale,
+ sc.secret_finding_count AS secretFindingCount,
+ sc.mtime_ms AS mtimeMs, sc.text,
+ bm25(search_chunks_fts) AS ftsRank
+ FROM search_chunks_fts
+ JOIN search_chunks sc ON sc.rowid = search_chunks_fts.rowid
+ WHERE search_chunks_fts MATCH ?
+ AND sc.stale = 0
+ AND sc.secret_finding_count = 0
+ ORDER BY ftsRank
+ LIMIT ?
+ """,
+ (match, candidate_limit),
+ ).fetchall()
+
+ results = [
+ {
+ "id": r["id"],
+ "score": _norm_score(float(r["ftsRank"] or 0), int(r["authority"] or 0)),
+ "relPath": r["relPath"] or "",
+ "path": r["path"] or "",
+ "project": r["project"] or "",
+ "kind": r["kind"] or "doc",
+ "title": r["title"] or "",
+ "symbol": r["symbol"],
+ "startLine": r["startLine"] or 0,
+ "endLine": r["endLine"] or 0,
+ "authority": r["authority"] or 0,
+ "stale": bool(r["stale"]),
+ "secretFindingCount": r["secretFindingCount"] or 0,
+ "mtimeMs": r["mtimeMs"] or 0,
+ "text": (r["text"] or "")[:600],
+ }
+ for r in rows
+ ]
+ results.sort(key=lambda x: -x["score"])
+ return results[:limit]
+
+ except sqlite3.DatabaseError:
+ # FTS B-tree corrupt mid-rebuild - fall through to streaming
+ return None
+ finally:
+ con.close()
+
+
+def _stream_search(query: str, limit: int) -> list[dict]:
+ """Streaming keyword scan of index.jsonl.
+
+ Scans the first STREAM_SCAN_BYTES bytes (~80MB) to keep latency ~0.4s.
+
+ Scoring:
+ - ALL terms matched in content: 0.7 base + authority bonus (0..0.3)
+ - ALL terms matched in title: extra +0.2
+ - Partial match (some terms): 0.35 base only - returned as fallback
+ Results requiring all terms are always ranked above partial matches.
+ """
+ terms = _fts_terms(query)
+ if not terms:
+ return []
+
+ full_hits: list[dict] = []
+ partial_hits: list[dict] = []
+
+ try:
+ with open(INDEX_JSONL, "r", errors="replace") as f:
+ total_bytes = 0
+ for line in f:
+ total_bytes += len(line)
+ if total_bytes > STREAM_SCAN_BYTES:
+ break
+ line = line.strip()
+ if not line:
+ continue
+ # Fast pre-check: primary/distinctive term must be in line
+ # Use last term (often most specific, e.g. "hermes" vs "status")
+ line_lower = line.lower()
+ primary = terms[-1] # last term tends to be most specific
+ if primary not in line_lower:
+ continue
+ # Count all term matches
+ matched = [t for t in terms if t in line_lower]
+ is_full = len(matched) == len(terms)
+ if not is_full and len(matched) < max(1, len(terms) // 2):
+ continue
+ try:
+ obj = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if obj.get("secretFindingCount", 0) > 0 or obj.get("stale"):
+ continue
+ authority = int(obj.get("authority", 0))
+ title_lower = (obj.get("title") or "").lower()
+ title_match = sum(1 for t in terms if t in title_lower)
+
+ if is_full:
+ # Full match: high score
+ base = 0.70 + min(0.25, authority * 0.04)
+ title_bonus = min(0.15, title_match * 0.08)
+ score = round(min(1.0, base + title_bonus), 4)
+ text = obj.get("text", "")
+ full_hits.append({
+ "id": obj.get("id", ""),
+ "score": score,
+ "relPath": obj.get("relPath", ""),
+ "path": obj.get("path", ""),
+ "project": obj.get("project", ""),
+ "kind": obj.get("kind", "doc"),
+ "title": obj.get("title", ""),
+ "symbol": obj.get("symbol"),
+ "startLine": obj.get("startLine", 0),
+ "endLine": obj.get("endLine", 0),
+ "authority": authority,
+ "stale": False,
+ "secretFindingCount": 0,
+ "mtimeMs": obj.get("mtimeMs", 0),
+ "text": str(text)[:600],
+ })
+ else:
+ # Partial match: lower score, used only if not enough full hits
+ score = round(min(0.50, 0.25 + authority * 0.04), 4)
+ text = obj.get("text", "")
+ partial_hits.append({
+ "id": obj.get("id", ""),
+ "score": score,
+ "relPath": obj.get("relPath", ""),
+ "path": obj.get("path", ""),
+ "project": obj.get("project", ""),
+ "kind": obj.get("kind", "doc"),
+ "title": obj.get("title", ""),
+ "symbol": obj.get("symbol"),
+ "startLine": obj.get("startLine", 0),
+ "endLine": obj.get("endLine", 0),
+ "authority": authority,
+ "stale": False,
+ "secretFindingCount": 0,
+ "mtimeMs": obj.get("mtimeMs", 0),
+ "text": str(text)[:600],
+ })
+ except OSError:
+ pass
+
+ full_hits.sort(key=lambda x: -x["score"])
+ partial_hits.sort(key=lambda x: -x["score"])
+
+ combined = full_hits + partial_hits
+ return combined[:limit]
+
+
+def _parse_iso(val: str | None) -> datetime | None:
+ if not val:
+ return None
+ try:
+ return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
+ except Exception:
+ return None
+
+
+def do_sources() -> dict:
+ """Per-source ingest freshness from homelab-rag state.sqlite.
+
+ Freshness signal = max(sync_runs.finished_at) per source (the last time the
+ rolling collector completed a run for that endpoint). Falls back to
+ max(source_files.last_seen_at) if no sync_runs row exists. staleness_h is
+ hours since that timestamp, relative to now (UTC).
+
+ Read-only immutable snapshot - never writes, never blocks the collector.
+ """
+ now = datetime.now(timezone.utc)
+ out: list[dict] = []
+ try:
+ con = sqlite3.connect(
+ f"file:{STATE_DB}?immutable=1",
+ uri=True,
+ timeout=1.5,
+ check_same_thread=False,
+ )
+ con.row_factory = sqlite3.Row
+ except sqlite3.OperationalError:
+ return {"ok": False, "now": now.isoformat(), "sources": []}
+
+ try:
+ rows = con.execute(
+ """
+ SELECT s.id AS source_id, s.host, s.type, s.cadence, s.enabled,
+ MAX(r.finished_at) AS last_sync,
+ (SELECT files_seen FROM sync_runs r2
+ WHERE r2.source_id = s.id
+ ORDER BY r2.finished_at DESC LIMIT 1) AS files_seen,
+ (SELECT MAX(last_seen_at) FROM source_files sf
+ WHERE sf.source_id = s.id) AS max_seen
+ FROM sources s
+ LEFT JOIN sync_runs r ON r.source_id = s.id
+ GROUP BY s.id
+ """
+ ).fetchall()
+ except sqlite3.DatabaseError:
+ con.close()
+ return {"ok": False, "now": now.isoformat(), "sources": []}
+
+ for r in rows:
+ last = _parse_iso(r["last_sync"]) or _parse_iso(r["max_seen"])
+ staleness_h = None
+ last_iso = None
+ if last is not None:
+ last_iso = last.isoformat()
+ staleness_h = round((now - last).total_seconds() / 3600.0, 2)
+ out.append({
+ "source_id": r["source_id"],
+ "host": r["host"] or "",
+ "type": r["type"] or "",
+ "cadence": r["cadence"] or "",
+ "enabled": bool(r["enabled"]),
+ "last_ingest": last_iso,
+ "staleness_h": staleness_h,
+ "files_seen": r["files_seen"] or 0,
+ })
+ con.close()
+ out.sort(key=lambda x: (x["staleness_h"] is None, x["staleness_h"] or 0))
+ return {"ok": True, "now": now.isoformat(), "sources": out}
+
+
+def do_search(query: str, limit: int) -> list[dict]:
+ """Search: try SQLite FTS first, fall back to streaming scan."""
+ limit = max(1, min(limit, MAX_RESULTS))
+ result = _sqlite_search(query, limit)
+ if result is not None:
+ return result
+ return _stream_search(query, limit)
+
+
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self, fmt, *args):
+ pass # silence access log
+
+ def send_json(self, code: int, data):
+ body = json.dumps(data).encode()
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ parsed = urlparse(self.path)
+ path = parsed.path
+ qs = parse_qs(parsed.query)
+
+ if path == "/health":
+ sqlite_ok = False
+ stream_ok = os.path.exists(INDEX_JSONL)
+ try:
+ con = sqlite3.connect(
+ f"file:{STATE_DB}?immutable=1", uri=True, timeout=0.5
+ )
+ con.execute("SELECT 1")
+ con.close()
+ sqlite_ok = True
+ except Exception:
+ pass
+ self.send_json(200, {
+ "ok": True,
+ "sqlite": sqlite_ok,
+ "stream_fallback": stream_ok,
+ "db": STATE_DB,
+ })
+ return
+
+ if path == "/sources":
+ try:
+ self.send_json(200, do_sources())
+ except Exception as e:
+ self.send_json(500, {"ok": False, "error": str(e), "sources": []})
+ return
+
+ if path == "/search":
+ query = (qs.get("q") or qs.get("query") or [""])[0].strip()
+ try:
+ limit = int((qs.get("limit") or [str(DEFAULT_LIMIT)])[0])
+ except ValueError:
+ limit = DEFAULT_LIMIT
+ if not query:
+ self.send_json(400, {"error": "q parameter required"})
+ return
+ t0 = time.time()
+ try:
+ results = do_search(query, limit)
+ elapsed = time.time() - t0
+ body = json.dumps(results).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("X-Search-Ms", str(int(elapsed * 1000)))
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ except Exception as e:
+ self.send_json(500, {"error": str(e)})
+ return
+
+ self.send_json(404, {"error": "not found"})
+
+
+def main():
+ server = HTTPServer(("0.0.0.0", PORT), Handler)
+ print(f"Darwin RAG bridge listening on :{PORT}", flush=True)
+ print(f"State DB: {STATE_DB}", flush=True)
+ print(f"Index jsonl: {INDEX_JSONL}", flush=True)
+ server.serve_forever()
+
+
+if __name__ == "__main__":
+ main()
term/main.py +500 -0
@@ -0,0 +1,500 @@
+"""darwin-term - standalone WS→PTY→SSH terminal bridge for Darwin agent panes.
+
+Wire protocol (compatible with standard xterm.js client):
+ WS /ws?target=&agent=&mode=&session=&cwd=[&token=]
+ client→server:
+ binary frames - raw keystrokes/input bytes sent to PTY
+ text JSON - {"t":"resize","cols":N,"rows":N} → TIOCSWINSZ
+ server→client:
+ binary frames - raw PTY output
+ text (string) - banner lines + JSON control:
+ {"_cockpit":"session","name":<tmux-name>} (persistent mode only)
+
+Auth: DARWIN_TERM_TOKEN env var. Pass as ?token= query param or
+ Authorization: Bearer <token> header. Missing/wrong → close 4403.
+ (Future: replace with Authentik SSO.)
+
+Targets: ct215 (REDACTED-IP), ct241 (REDACTED-IP), ct247 (REDACTED-IP)
+SSH key: /root/.ssh/termius_homelab (root auth, key-based, no password)
+Port: 9894
+"""
+
+import asyncio
+import fcntl
+import json
+import logging
+import os
+import pty
+import re
+import select
+import shlex
+import struct
+import termios
+import uuid
+from contextlib import asynccontextmanager
+
+import uvicorn
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
+from fastapi.responses import JSONResponse
+
+# ---------------------------------------------------------------------------
+# Config
+# ---------------------------------------------------------------------------
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
+logger = logging.getLogger("darwin-term")
+
+SSH_KEY = os.environ.get("DARWIN_TERM_SSH_KEY", "/root/.ssh/termius_homelab")
+KNOWN_HOSTS = "/root/.ssh/known_hosts"
+DARWIN_TERM_TOKEN = os.environ.get("DARWIN_TERM_TOKEN", "")
+
+# ---------------------------------------------------------------------------
+# Target / agent maps (mirrored from cockpit_routes.py)
+# ---------------------------------------------------------------------------
+COMMON_DIRS = [
+ {"label": "Default (home ~)", "path": ""},
+ {"label": "Projects - /shared/projects", "path": "/shared/projects"},
+ {"label": "Oversight - /shared/projects/Oversight", "path": "/shared/projects/Oversight"},
+]
+
+TARGETS: dict[str, dict] = {
+ "ct215": {
+ "ip": "REDACTED-IP",
+ "label": "Claude Code - CT215",
+ "agents": ["claude", "shell"],
+ "default_agent": "claude",
+ "default_cwd": "/shared/projects",
+ },
+ "ct241": {
+ "ip": "REDACTED-IP",
+ "label": "Codex - CT241",
+ "agents": ["codex", "shell"],
+ "default_agent": "codex",
+ "default_cwd": "/shared/projects",
+ },
+ "ct247": {
+ "ip": "REDACTED-IP",
+ "label": "OpenCode / GLM - CT247",
+ "agents": ["opencode", "shell"],
+ "default_agent": "opencode",
+ "default_cwd": "",
+ },
+}
+
+AGENT_CMD: dict[str, str] = {
+ # AGENT_TEAMS=0: don't spawn nested tmux sessions inside our pane.
+ # IS_SANDBOX=1: allow --dangerously-skip-permissions in the isolated LXC.
+ "claude": "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 IS_SANDBOX=1 claude --dangerously-skip-permissions",
+ "codex": "codex",
+ "opencode": "opencode",
+}
+
+SESSION_PREFIXES = tuple(f"{a}-" for a in list(AGENT_CMD) + ["shell"])
+
+_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,80}$")
+_CWD_RE = re.compile(r"^[A-Za-z0-9_./~ -]{1,256}$")
+
+
+# ---------------------------------------------------------------------------
+# Auth helpers
+# ---------------------------------------------------------------------------
+def _check_token(token_param: str | None, auth_header: str | None) -> bool:
+ """Return True if the request carries the correct bearer token."""
+ if not DARWIN_TERM_TOKEN:
+ # No token configured → open (dev mode warning logged at startup)
+ return True
+ candidate = token_param or ""
+ if not candidate and auth_header and auth_header.lower().startswith("bearer "):
+ candidate = auth_header[7:]
+ return candidate == DARWIN_TERM_TOKEN
+
+
+# ---------------------------------------------------------------------------
+# SSH / PTY helpers (ported 1-for-1 from cockpit_routes.py)
+# ---------------------------------------------------------------------------
+def _ssh_argv(ip: str) -> list[str]:
+ return [
+ "ssh", "-tt",
+ "-i", SSH_KEY,
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=10",
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-o", f"UserKnownHostsFile={KNOWN_HOSTS}",
+ "-o", "ServerAliveInterval=20",
+ f"root@{ip}",
+ ]
+
+
+def _tmux_attach(session: str, payload: str) -> str:
+ """Create-or-attach a tmux session with deep history and mouse OFF.
+ `mouse off` keeps terminal output in xterm.js's own scrollback (the browser
+ viewport) instead of being captured into tmux copy-mode - which touch can't
+ drive - so the phone can finger-scroll terminal history. A large history-limit
+ still keeps the underlying tmux buffer deep across reattach."""
+ q = shlex.quote(session)
+ # Detached create (idempotent) so we can set options, then attach.
+ create = f"tmux new-session -d -A -s {q} {shlex.quote(payload)}"
+ # mouse OFF (output → xterm scrollback, touch-scrollable) + deep history.
+ # window-size=smallest + aggressive-resize: a session attached from BOTH the
+ # PC and the phone must render at the SMALLEST client's size, else tmux keeps
+ # the PC's wide grid and the phone shows a clipped slice (the right-edge cut-off).
+ core = (
+ "tmux set-option -g mouse off >/dev/null 2>&1; "
+ "tmux set-option -g history-limit 50000 >/dev/null 2>&1; "
+ "tmux set-option -g window-size smallest >/dev/null 2>&1; "
+ "tmux set-option -g aggressive-resize on >/dev/null 2>&1"
+ )
+ # cosmetic on-brand status bar (best-effort; guarded so old tmux can't break attach)
+ skin = "tmux set-option -g status-style 'bg=colour234,fg=colour37' >/dev/null 2>&1 || true"
+ attach = f"tmux attach-session -t {q}"
+ return f"{create}; {core}; {skin}; {attach}"
+
+
+def _remote_command(agent: str, mode: str, session: str, cwd: str | None) -> str:
+ if agent == "shell":
+ inner = "bash -l"
+ if cwd:
+ inner = f"bash -lc {shlex.quote(f'cd {shlex.quote(cwd)} && exec bash -l')}"
+ if mode == "persistent":
+ payload = inner if cwd else "bash -l"
+ return _tmux_attach(session, payload)
+ return inner
+
+ base = AGENT_CMD[agent]
+ if cwd:
+ base = f"cd {shlex.quote(cwd)} && {base}"
+ run = f"bash -lc {shlex.quote(base)}"
+ if mode == "persistent":
+ return _tmux_attach(session, run)
+ return run
+
+
+def _valid_session(name: str) -> bool:
+ return bool(name) and bool(_NAME_RE.match(name)) and name.startswith(SESSION_PREFIXES)
+
+
+def _read_fd(fd: int) -> bytes | None:
+ """Blocking read via select; None = no data yet, b'' = EOF."""
+ r, _, _ = select.select([fd], [], [], 1.0)
+ if r:
+ try:
+ data = os.read(fd, 65536)
+ return data if data else b""
+ except OSError:
+ return b""
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Remote helper: run ssh command, capture stdout
+# ---------------------------------------------------------------------------
+async def _run_ssh(ip: str, remote_cmd: str, timeout: float = 12.0) -> tuple[int, str, str]:
+ argv = [
+ "ssh", "-i", SSH_KEY,
+ "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-o", f"UserKnownHostsFile={KNOWN_HOSTS}",
+ f"root@{ip}", remote_cmd,
+ ]
+ proc = await asyncio.create_subprocess_exec(
+ *argv,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ try:
+ proc.kill()
+ except ProcessLookupError:
+ pass
+ return -1, "", "timeout"
+ return proc.returncode, out.decode(errors="replace"), err.decode(errors="replace")
+
+
+# ---------------------------------------------------------------------------
+# Session list helper
+# ---------------------------------------------------------------------------
+async def _list_sessions() -> list[dict]:
+ results: list[dict] = []
+
+ async def one(key: str, meta: dict):
+ _rc, out, _err = await _run_ssh(
+ meta["ip"],
+ "tmux list-sessions -F '#{session_name}|#{session_created}|#{session_attached}' 2>/dev/null || true",
+ )
+ for line in out.splitlines():
+ parts = line.strip().split("|")
+ if not parts or not parts[0]:
+ continue
+ name = parts[0]
+ if not name.startswith(SESSION_PREFIXES):
+ continue
+ results.append({
+ "target": key,
+ "label": meta["label"],
+ "session": name,
+ "agent": name.split("-", 1)[0],
+ "created": parts[1] if len(parts) > 1 else "",
+ "attached": (parts[2] == "1") if len(parts) > 2 else False,
+ })
+
+ await asyncio.gather(*(one(k, m) for k, m in TARGETS.items()))
+ results.sort(key=lambda r: (r["target"], r["session"]))
+ return results
+
+
+# ---------------------------------------------------------------------------
+# FastAPI app
+# ---------------------------------------------------------------------------
+@asynccontextmanager
+async def lifespan(_app: FastAPI):
+ if not DARWIN_TERM_TOKEN:
+ logger.warning("DARWIN_TERM_TOKEN is not set - service is OPEN, set it in /opt/darwin/.env")
+ else:
+ logger.info("darwin-term auth: token configured (length %d)", len(DARWIN_TERM_TOKEN))
+ logger.info("SSH key: %s", SSH_KEY)
+ yield
+
+
+app = FastAPI(title="darwin-term", version="1.0.0", lifespan=lifespan)
+
+
+# ---------------------------------------------------------------------------
+# GET /health
+# ---------------------------------------------------------------------------
+@app.get("/health")
+async def health():
+ return {"status": "ok", "service": "darwin-term"}
+
+
+# ---------------------------------------------------------------------------
+# GET /targets
+# ---------------------------------------------------------------------------
+@app.get("/targets")
+async def targets(
+ token: str | None = Query(default=None),
+ authorization: str | None = None,
+):
+ # Light auth on HTTP endpoints too
+ if not _check_token(token, authorization):
+ return JSONResponse({"error": "unauthorized"}, status_code=403)
+ return {
+ "targets": [
+ {
+ "key": k,
+ "ip": m["ip"],
+ "label": m["label"],
+ "agents": m["agents"],
+ "default_agent": m["default_agent"],
+ "default_cwd": m.get("default_cwd", ""),
+ "dirs": COMMON_DIRS,
+ }
+ for k, m in TARGETS.items()
+ ],
+ "agent_cmds": AGENT_CMD,
+ "common_dirs": COMMON_DIRS,
+ }
+
+
+# ---------------------------------------------------------------------------
+# GET /sessions
+# ---------------------------------------------------------------------------
+@app.get("/sessions")
+async def sessions(
+ token: str | None = Query(default=None),
+ authorization: str | None = None,
+):
+ if not _check_token(token, authorization):
+ return JSONResponse({"error": "unauthorized"}, status_code=403)
+ return {"sessions": await _list_sessions()}
+
+
+# ---------------------------------------------------------------------------
+# POST /kill - terminate a persistent tmux session
+# ---------------------------------------------------------------------------
+@app.post("/kill")
+async def kill(
+ target: str | None = Query(default=None),
+ session: str | None = Query(default=None),
+ token: str | None = Query(default=None),
+ authorization: str | None = None,
+):
+ if not _check_token(token, authorization):
+ return JSONResponse({"error": "unauthorized"}, status_code=403)
+ if target not in TARGETS:
+ return JSONResponse({"error": "unknown target"}, status_code=400)
+ if not _valid_session(session or ""):
+ return JSONResponse({"error": "invalid session"}, status_code=400)
+ meta = TARGETS[target]
+ rc, _out, err = await _run_ssh(
+ meta["ip"],
+ f"tmux kill-session -t {session} 2>&1 || true",
+ )
+ return {"ok": rc == 0, "session": session, "detail": err.strip()}
+
+
+# ---------------------------------------------------------------------------
+# WS /ws
+# ---------------------------------------------------------------------------
+@app.websocket("/ws")
+async def terminal_ws(websocket: WebSocket):
+ q = websocket.query_params
+ token_param = q.get("token")
+ auth_header = websocket.headers.get("authorization")
+
+ if not _check_token(token_param, auth_header):
+ await websocket.close(code=4403)
+ return
+
+ target = q.get("target", "")
+ agent = q.get("agent", "shell")
+ mode = "persistent" if q.get("mode") == "persistent" else "ephemeral"
+ session = q.get("session", "")
+ cwd = q.get("cwd") or None
+
+ if target not in TARGETS:
+ await websocket.accept()
+ await websocket.send_text("\r\n[darwin-term] unknown target\r\n")
+ await websocket.close()
+ return
+
+ meta = TARGETS[target]
+
+ # Normalise agent
+ if agent not in ("shell",) and agent not in meta["agents"]:
+ agent = meta["default_agent"]
+
+ if cwd and not _CWD_RE.match(cwd):
+ await websocket.accept()
+ await websocket.send_text("\r\n[darwin-term] invalid cwd\r\n")
+ await websocket.close()
+ return
+
+ # Resolve / mint tmux session name for persistent mode
+ if mode == "persistent":
+ if _valid_session(session):
+ pass # reattach existing
+ else:
+ session = f"{agent}-{uuid.uuid4().hex[:8]}"
+ else:
+ session = ""
+
+ remote_cmd = _remote_command(agent, mode, session, cwd)
+ argv = _ssh_argv(meta["ip"]) + [remote_cmd]
+
+ await websocket.accept()
+ banner = (
+ f"[darwin-term] {meta['label']} · {agent} · {mode}"
+ + (f" · {session}" if session else "")
+ + "\r\n"
+ )
+ await websocket.send_text(banner)
+ if mode == "persistent":
+ await websocket.send_text(json.dumps({"_cockpit": "session", "name": session}) + "\r\n")
+
+ loop = asyncio.get_running_loop()
+ master_fd, slave_fd = pty.openpty()
+
+ # Set non-blocking on master so reads don't stall the event loop thread
+ flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
+
+ # Seed a sane 24×80 window; client will send a real resize immediately
+ try:
+ fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
+ except OSError:
+ pass
+
+ proc = await asyncio.create_subprocess_exec(
+ *argv,
+ stdin=slave_fd,
+ stdout=slave_fd,
+ stderr=slave_fd,
+ preexec_fn=os.setsid,
+ env={**os.environ, "TERM": "xterm-256color"},
+ )
+ os.close(slave_fd)
+
+ def _set_winsize(rows: int, cols: int):
+ try:
+ fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
+ except OSError:
+ pass
+
+ stop = asyncio.Event()
+
+ async def pty_to_ws():
+ try:
+ while not stop.is_set():
+ try:
+ data = await loop.run_in_executor(None, _read_fd, master_fd)
+ except OSError:
+ break
+ if data is None:
+ continue
+ if data == b"":
+ break
+ try:
+ await websocket.send_bytes(data)
+ except Exception:
+ break
+ finally:
+ stop.set()
+
+ async def ws_to_pty():
+ try:
+ while not stop.is_set():
+ msg = await websocket.receive()
+ if msg.get("type") == "websocket.disconnect":
+ break
+ if msg.get("bytes") is not None:
+ os.write(master_fd, msg["bytes"])
+ elif msg.get("text") is not None:
+ text = msg["text"]
+ if text.startswith('{"t":"resize"'):
+ try:
+ c = json.loads(text)
+ _set_winsize(int(c.get("rows", 24)), int(c.get("cols", 80)))
+ continue
+ except Exception:
+ pass
+ os.write(master_fd, text.encode())
+ except WebSocketDisconnect:
+ pass
+ except Exception:
+ pass
+ finally:
+ stop.set()
+
+ reader = asyncio.create_task(pty_to_ws())
+ writer = asyncio.create_task(ws_to_pty())
+ try:
+ await stop.wait()
+ finally:
+ reader.cancel()
+ writer.cancel()
+ try:
+ proc.terminate()
+ except ProcessLookupError:
+ pass
+ try:
+ os.close(master_fd)
+ except OSError:
+ pass
+ try:
+ await websocket.close()
+ except Exception:
+ pass
+ logger.info("session closed: target=%s agent=%s mode=%s session=%s", target, agent, mode, session or "-")
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+if __name__ == "__main__":
+ uvicorn.run(
+ "main:app",
+ host="0.0.0.0",
+ port=9894,
+ log_level="info",
+ )