| @@ -0,0 +1,15 @@ | ||
| + | node_modules/ | |
| + | .env | |
| + | .env.* | |
| + | !.env.example | |
| + | .venv/ | |
| + | venv/ | |
| + | __pycache__/ | |
| + | *.pyc | |
| + | dist/ | |
| + | build/ | |
| + | .next/ | |
| + | out/ | |
| + | *.log | |
| + | _logs/ | |
| + | .DS_Store |
| @@ -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. |
| @@ -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) |
| @@ -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. |
| @@ -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. |
| @@ -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. |
| @@ -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. |
| @@ -0,0 +1 @@ | ||
| + | # Darwin Cortex engine adapters |
| @@ -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) |
| @@ -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] |
| @@ -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] |
| @@ -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 [] |
| @@ -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] |
| @@ -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] |
| @@ -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] |
| @@ -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 |
| @@ -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"} |
| @@ -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()) |
| @@ -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 |
| @@ -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) |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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 == [] |
| @@ -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!" | |
| + | ) |
| @@ -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). |
| @@ -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()) |
| @@ -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 |
| @@ -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 |
| @@ -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()) |
| @@ -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") |
| @@ -0,0 +1,5 @@ | ||
| + | fastapi==0.115.0 | |
| + | uvicorn[standard]==0.30.6 | |
| + | websockets==12.0 | |
| + | pydantic==2.9.2 | |
| + | watchdog==4.0.2 |
| @@ -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 ===" |
| @@ -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*. |
| @@ -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). |
| @@ -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/swipe | |
| + | 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. |
| @@ -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> |
| @@ -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} |
| @@ -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. |
| @@ -0,0 +1,7 @@ | ||
| + | /** @type {import('next').NextConfig} */ | |
| + | const nextConfig = { | |
| + | output: "standalone", | |
| + | transpilePackages: ["three", "@react-three/fiber", "@react-three/drei"], | |
| + | }; | |
| + | ||
| + | export default nextConfig; |
| @@ -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 | |
| + | } | |
| + | } | |
| + | } | |
| + | } | |
| + | } |
| @@ -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" | |
| + | } | |
| + | } |
| @@ -0,0 +1,6 @@ | ||
| + | module.exports = { | |
| + | plugins: { | |
| + | tailwindcss: {}, | |
| + | autoprefixer: {}, | |
| + | }, | |
| + | }; |
| @@ -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 }); | |
| + | } | |
| + | } |
| @@ -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" } } | |
| + | ); | |
| + | } |
| @@ -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 }); | |
| + | } | |
| + | } |
| @@ -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 }); | |
| + | } | |
| + | } |
| @@ -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; | |
| + | } | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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 />; | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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; |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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> | |
| + | ); | |
| + | } |
| @@ -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 }; | |
| + | } |
| @@ -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 }; | |
| + | } |
| @@ -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 }; | |
| + | } |
| @@ -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, "&") | |
| + | .replace(/</g, "<") | |
| + | .replace(/>/g, ">"); | |
| + | } | |
| + | ||
| + | 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(""); | |
| + | } |
| @@ -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)); | |
| + | } |
| @@ -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()}`; | |
| + | } |
| @@ -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; | |
| + | } |
| @@ -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; |
| @@ -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"] | |
| + | } |
| @@ -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. |
| @@ -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 |
| @@ -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 | |
| + | } |
| @@ -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() |
| @@ -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." |
| @@ -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" |
| @@ -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() |
| @@ -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", | |
| + | ) |