diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..4bb83cc --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1d83cc7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,316 @@ +{ + description = "Spatial Office — isometric collaborative workspace for agents and humans"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + # ── Node.js 22 toolchain ────────────────────────────────────── + nodeToolchain = with pkgs; [ + nodejs_22 + corepack_22 + ]; + + # ── Frontend package ────────────────────────────────────────── + # React + @react-three/fiber + Zustand, built with Vite + frontend = pkgs.buildNpmPackage { + name = "spatial-office-frontend"; + version = "0.1.0"; + + src = ./packages/frontend; + + npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + nativeBuildInputs = with pkgs; [ + nodejs_22 + corepack_22 + ]; + + buildPhase = '' + runHook preBuild + corepack pnpm run build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r dist/* $out/ + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "Spatial Office frontend — React + Three.js isometric workspace"; + license = licenses.mit; + }; + }; + + # ── Backend package ─────────────────────────────────────────── + # Node.js + ws + Zod, WebSocket server on port 18790 + backend = pkgs.buildNpmPackage { + name = "spatial-office-backend"; + version = "0.1.0"; + + src = ./packages/backend; + + npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + nativeBuildInputs = with pkgs; [ + nodejs_22 + corepack_22 + ]; + + buildPhase = '' + runHook preBuild + corepack pnpm run build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/lib $out/bin + + # Copy built JS output + cp -r dist/* $out/lib/ + + # Copy node_modules for runtime deps (ws, zod, etc.) + cp -r node_modules $out/lib/ + + # Wrapper script + cat > $out/bin/spatial-office-backend << 'WRAPPER' + #!${pkgs.bash}/bin/bash + exec ${pkgs.nodejs_22}/bin/node "$out/lib/index.js" "$@" + WRAPPER + chmod +x $out/bin/spatial-office-backend + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "Spatial Office backend — Node.js WebSocket server with OpenClaw bridge"; + license = licenses.mit; + mainProgram = "spatial-office-backend"; + }; + }; + + # ── Combined package (symlink forest of frontend + backend) ──── + spatial-office = pkgs.buildEnv { + name = "spatial-office"; + version = "0.1.0"; + paths = [ frontend backend ]; + meta = with pkgs.lib; { + description = "Spatial Office — full application (frontend + backend)"; + license = licenses.mit; + }; + }; + + in + { + packages = { + inherit frontend backend; + default = spatial-office; + }; + + devShells = { + default = pkgs.mkShell { + name = "spatial-office-dev"; + buildInputs = nodeToolchain ++ (with pkgs; [ ]); + + shellHook = '' + echo "" + echo "🏢 Spatial Office dev shell" + echo " pnpm install — install workspace dependencies" + echo " pnpm dev — start frontend + backend in dev mode" + echo " pnpm build — production build" + echo " nix build — full Nix build" + echo "" + ''; + }; + }; + + # ── NixOS module ─────────────────────────────────────────────── + nixosModules = { + spatial-office = { config, lib, pkgs, ... }: + let + cfg = config.services.spatial-office; + in + { + options.services.spatial-office = { + enable = lib.mkEnableOption "Spatial Office service"; + + port = lib.mkOption { + type = lib.types.int; + default = 18790; + description = "WebSocket server port"; + }; + + openclawTokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "File containing the OpenClaw gateway shared token"; + }; + + frontendDir = lib.mkOption { + type = lib.types.path; + default = "${self.packages.${system}.frontend}"; + description = "Directory of built frontend static files to serve"; + }; + + logLevel = lib.mkOption { + type = lib.types.enum [ "debug" "info" "warn" "error" ]; + default = "info"; + description = "Log level for the spatial-office backend"; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.spatial-office = { + description = "Spatial Office Backend"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + wants = [ "network.target" ]; + requires = [ ]; + + environment = { + PORT = toString cfg.port; + SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir; + LOG_LEVEL = cfg.logLevel; + NODE_ENV = "production"; + OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; + } // (lib.optionalAttrs (cfg.openclawTokenFile != null) { + OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile; + }); + + serviceConfig = { + ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend"; + Restart = "always"; + RestartSec = 5; + User = "spatial-office"; + Group = "spatial-office"; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + ReadWritePaths = [ ]; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + MemoryDenyWriteExecute = false; # Node.js needs W^X disabled + RestrictRealtime = true; + SystemCallFilter = [ "@system-service" "~@privileged" ]; + }; + }; + + # Service user + users.users.spatial-office = { + isSystemUser = true; + group = "spatial-office"; + description = "Spatial Office service user"; + }; + users.groups.spatial-office = { }; + + # ── Nginx reverse proxy to serve frontend static files ── + services.nginx = { + enable = true; + virtualHosts."n97" = lib.mkMerge [ + (lib.mkIf (config.services.nginx.virtualHosts ? "n97") { }) + { + listen = lib.mkIf (config.services.nginx.virtualHosts ? "n97") [ ]; + } + ]; + virtualHosts."spatial-office" = { + listen = [{ + addr = "0.0.0.0"; + port = cfg.port; + }]; + root = cfg.frontendDir; + locations."/" = { + index = "index.html"; + tryFiles = "$uri $uri/ /index.html"; + }; + locations."/ws" = { + proxyPass = "http://127.0.0.1:${toString (cfg.port + 1)}"; + proxyWebsockets = true; + }; + }; + }; + + # Firewall — open the frontend port + networking.firewall.allowedTCPPorts = [ cfg.port ]; + }; + }; + + # ── Alias: spatial-office.nix module (just the systemd service, no nginx) ── + spatial-office-service = { config, lib, pkgs, ... }: + let + cfg = config.services.spatial-office; + in + { + options.services.spatial-office = { + enable = lib.mkEnableOption "Spatial Office service"; + port = lib.mkOption { + type = lib.types.int; + default = 18790; + }; + openclawTokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + }; + frontendDir = lib.mkOption { + type = lib.types.path; + default = "${self.packages.${system}.frontend}"; + }; + logLevel = lib.mkOption { + type = lib.types.enum [ "debug" "info" "warn" "error" ]; + default = "info"; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.spatial-office = { + description = "Spatial Office Backend"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + environment = { + PORT = toString cfg.port; + SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir; + LOG_LEVEL = cfg.logLevel; + NODE_ENV = "production"; + OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; + } // (lib.optionalAttrs (cfg.openclawTokenFile != null) { + OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile; + }); + + serviceConfig = { + ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend"; + Restart = "always"; + RestartSec = 5; + User = "spatial-office"; + Group = "spatial-office"; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + }; + }; + + users.users.spatial-office = { + isSystemUser = true; + group = "spatial-office"; + }; + users.groups.spatial-office = { }; + + networking.firewall.allowedTCPPorts = [ cfg.port ]; + }; + }; + }; + } + ); +} diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..f64038b --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,21 @@ +{ + "name": "spatial-office-backend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "mkdir -p dist && cp src/index.ts dist/index.js", + "start": "node dist/index.js" + }, + "dependencies": { + "ws": "^8.16.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts new file mode 100644 index 0000000..ff7bd09 --- /dev/null +++ b/packages/backend/src/index.ts @@ -0,0 +1 @@ +// placeholder diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000..a5bddfc --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "spatial-office-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "mkdir -p dist && cp src/main.tsx dist/index.html", + "preview": "vite preview" + }, + "dependencies": { + "@react-three/drei": "^9.0.0", + "@react-three/fiber": "^8.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "three": "^0.170.0", + "zustand": "^5.0.0", + "gsap": "^3.12.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/three": "^0.170.0", + "typescript": "^5.4.0", + "vite": "^6.0.0", + "@vitejs/plugin-react": "^4.0.0" + } +} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx new file mode 100644 index 0000000..ff7bd09 --- /dev/null +++ b/packages/frontend/src/main.tsx @@ -0,0 +1 @@ +// placeholder diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/spatial-office.nix b/spatial-office.nix new file mode 100644 index 0000000..1a98575 --- /dev/null +++ b/spatial-office.nix @@ -0,0 +1,184 @@ +# spatial-office.nix — NixOS module for the Spatial Office service +# +# Import into your NixOS configuration: +# +# imports = [ ./spatial-office.nix ]; +# services.spatial-office.enable = true; +# services.spatial-office.port = 18790; +# services.spatial-office.openclawTokenFile = "/run/secrets/openclaw-token"; +# +# This module provides: +# - A systemd service running the spatial-office backend +# - A dedicated spatial-office system user +# - Firewall port opening for the configured port +# +# Requirements: +# - The spatial-office flake must be available as an input in your flake.nix +# - Or the spatial-office package must be in your nixpkgs overlay +# +# Usage with flake-parts or raw NixOS config: +# +# { config, inputs, pkgs, ... }: { +# imports = [ inputs.spatial-office.nixosModules.spatial-office-service ]; +# services.spatial-office = { +# enable = true; +# port = 18790; +# openclawTokenFile = config.sops.secrets.openclaw-token.path; +# }; +# } + +{ config, lib, pkgs, ... }: + +let + cfg = config.services.spatial-office; +in +{ + options.services.spatial-office = { + enable = lib.mkEnableOption "Spatial Office service — isometric agent workspace"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.spatial-office-backend or (throw '' + spatial-office package not found in nixpkgs. + Add the spatial-office flake as an input and set: + services.spatial-office.package = inputs.spatial-office.packages.''${pkgs.system}.backend; + ''); + description = "The spatial-office backend package to use"; + }; + + frontendPackage = lib.mkOption { + type = lib.types.package; + default = pkgs.spatial-office-frontend or (throw '' + spatial-office frontend package not found in nixpkgs. + Add the spatial-office flake as an input and set: + services.spatial-office.frontendPackage = inputs.spatial-office.packages.''${pkgs.system}.frontend; + ''); + description = "The spatial-office frontend package (static files)"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 18790; + description = "WebSocket server port for the spatial-office backend"; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Host address to bind the backend to"; + }; + + openclawTokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to a file containing the OpenClaw gateway shared token. + The file must be readable by the spatial-office user. + Use with sops-nix or agenix for secret management. + ''; + }; + + openclawGatewayUrl = lib.mkOption { + type = lib.types.str; + default = "ws://127.0.0.1:18789"; + description = "WebSocket URL of the OpenClaw gateway"; + }; + + frontendDir = lib.mkOption { + type = lib.types.path; + default = cfg.frontendPackage; + description = "Directory of built frontend static files to serve"; + }; + + logLevel = lib.mkOption { + type = lib.types.enum [ "debug" "info" "warn" "error" ]; + default = "info"; + description = "Log level for the spatial-office backend"; + }; + + extraEnvironment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Extra environment variables to pass to the service"; + }; + }; + + config = lib.mkIf cfg.enable { + # ── Systemd service ──────────────────────────────────────────── + systemd.services.spatial-office = { + description = "Spatial Office — isometric agent workspace backend"; + documentation = [ "https://os.scoble.me/forgejo/sscoble/spatial-office" ]; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "network-online.target" ]; + wants = [ "network-online.target" ]; + + # If a token file is configured, make sure it's available + unitConfig = lib.mkIf (cfg.openclawTokenFile != null) { + RequiresMountsFor = [ (builtins.dirOf cfg.openclawTokenFile) ]; + }; + + environment = { + PORT = toString cfg.port; + HOST = cfg.host; + SPATIAL_OFFICE_FRONTEND_DIR = "${cfg.frontendDir}"; + LOG_LEVEL = cfg.logLevel; + NODE_ENV = "production"; + OPENCLAW_GATEWAY_URL = cfg.openclawGatewayUrl; + } // (lib.optionalAttrs (cfg.openclawTokenFile != null) { + OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile; + }) // cfg.extraEnvironment; + + serviceConfig = { + ExecStart = "${cfg.package}/bin/spatial-office-backend"; + Restart = "always"; + RestartSec = 5; + + # Run as dedicated user + User = "spatial-office"; + Group = "spatial-office"; + + # Security hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictRealtime = true; + + # Node.js needs W^X disabled for JIT + MemoryDenyWriteExecute = false; + + # Allow network and basic syscalls + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + ]; + + # Restrict filesystem access + ReadWritePaths = [ ]; + ReadOnlyPaths = lib.optionals (cfg.openclawTokenFile != null) [ cfg.openclawTokenFile ]; + InaccessiblePaths = [ "/root" "/etc/ssh" ]; + + # Resource limits + LimitNOFILE = 4096; + }; + }; + + # ── Service user ─────────────────────────────────────────────── + users.users.spatial-office = { + isSystemUser = true; + group = "spatial-office"; + description = "Spatial Office service user"; + home = "/var/lib/spatial-office"; + createHome = true; + }; + users.groups.spatial-office = { }; + + # ── Firewall ─────────────────────────────────────────────────── + networking.firewall.allowedTCPPorts = [ cfg.port ]; + }; +} diff --git a/src/bridge-openclaw/package.json b/src/bridge-openclaw/package.json new file mode 100644 index 0000000..e676f78 --- /dev/null +++ b/src/bridge-openclaw/package.json @@ -0,0 +1,15 @@ +{ + "name": "@spatial-office/bridge-openclaw", + "version": "0.1.0", + "description": "OpenClaw Gateway bridge — maps agent state to spatial events", + "private": true, + "type": "module", + "main": "index.js", + "engines": { + "node": ">=22" + }, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.23.0" + } +} diff --git a/src/bridge-openclaw/types.js b/src/bridge-openclaw/types.js new file mode 100644 index 0000000..9e6d376 --- /dev/null +++ b/src/bridge-openclaw/types.js @@ -0,0 +1,219 @@ +// Spatial Office — OpenClaw Bridge: Data Model Types +// Uses Zod for runtime validation + type inference. +import { z } from "zod"; + +// ── Agent Status ──────────────────────────────────────────── +export const AgentStatus = z.enum(["idle", "active", "blocked", "offline"]); +export const AgentStateEnum = AgentStatus; // alias used in code + +// ── Department ────────────────────────────────────────────── +export const Department = z.enum([ + "cos", + "engineering", + "senior-engineering", + "trust", + "market", + "health", + "content", + "presentations", + "vault", +]); + +// ── Zone ──────────────────────────────────────────────────── +export const ZoneType = z.enum([ + "collaboration", + "solo", + "self-improvement", + "social", +]); + +export const SpatialZone = z.object({ + id: z.string(), + name: z.string(), + type: ZoneType, + occupants: z.array(z.string()), + tileRange: z.object({ + x: z.number().int().min(0), + y: z.number().int().min(0), + width: z.number().int().positive(), + height: z.number().int().positive(), + }), +}); + +// ── Grid Position ─────────────────────────────────────────── +export const GridPosition = z.object({ + x: z.number().int().min(0), + y: z.number().int().min(0), +}); + +// ── Spatial Agent ─────────────────────────────────────────── +export const SpatialAgent = z.object({ + id: z.string(), + name: z.string(), + department: Department, + state: AgentStatus, + currentZone: z.string(), + currentTask: z.string().nullable().optional(), + lastActivity: z.number(), + position: GridPosition, +}); + +// ── Office State ──────────────────────────────────────────── +export const OfficeMeta = z.object({ + gatewayUptime: z.number(), + connectedClients: z.number(), + scottPresent: z.boolean(), + lastUpdate: z.number(), +}); + +export const OfficeEvent = z.object({ + type: z.string(), + agentId: z.string().nullable(), + zoneId: z.string().nullable(), + payload: z.record(z.unknown()).optional(), + timestamp: z.number(), +}); + +export const BridgeState = z.object({ + agents: z.record(z.string(), SpatialAgent), + zones: z.record(z.string(), SpatialZone), + events: z.array(OfficeEvent), + meta: OfficeMeta, +}); + +// ── Agent Registry Entry ──────────────────────────────────── +export const AgentConfig = z.object({ + id: z.string(), + name: z.string(), + department: Department, + color: z.string().optional(), +}); + +// ── Default positions per agent ───────────────────────────── +// These are the "home" positions when an agent is idle. +export const DEFAULT_POSITIONS = { + cos: { x: 2, y: 5 }, + engineering: { x: 7, y: 5 }, + "senior-engineering": { x: 6, y: 5 }, + trust: { x: 3, y: 5 }, + market: { x: 14, y: 5 }, + health: { x: 18, y: 5 }, + content: { x: 15, y: 5 }, + presentations: { x: 13, y: 5 }, + vault: { x: 17, y: 5 }, +}; + +// ── Zone definitions (MVP layout) ─────────────────────────── +export const DEFAULT_ZONES = { + "solo-west": { + id: "solo-west", + name: "Solo West", + type: "solo", + occupants: [], + tileRange: { x: 0, y: 2, width: 5, height: 10 }, + }, + "pod-a": { + id: "pod-a", + name: "Collaboration Pod A", + type: "collaboration", + occupants: [], + tileRange: { x: 5, y: 2, width: 5, height: 6 }, + }, + "pod-b": { + id: "pod-b", + name: "Collaboration Pod B", + type: "collaboration", + occupants: [], + tileRange: { x: 10, y: 2, width: 5, height: 6 }, + }, + "solo-east": { + id: "solo-east", + name: "Solo East", + type: "solo", + occupants: [], + tileRange: { x: 15, y: 2, width: 5, height: 10 }, + }, + "coaching-corner": { + id: "coaching-corner", + name: "Coaching Corner", + type: "self-improvement", + occupants: [], + tileRange: { x: 5, y: 8, width: 10, height: 3 }, + }, + "coffee-bar": { + id: "coffee-bar", + name: "Coffee Bar", + type: "social", + occupants: [], + tileRange: { x: 5, y: 0, width: 10, height: 2 }, + }, +}; + +// ── Zone position assignments ─────────────────────────────── +// Where agents are placed within their zone. +export const ZONE_POSITIONS = { + "solo-west": [ + { x: 2, y: 5 }, + { x: 3, y: 5 }, + { x: 2, y: 7 }, + { x: 3, y: 7 }, + { x: 2, y: 9 }, + { x: 3, y: 9 }, + ], + "pod-a": [ + { x: 6, y: 3 }, + { x: 7, y: 3 }, + { x: 8, y: 3 }, + { x: 9, y: 3 }, + ], + "pod-b": [ + { x: 11, y: 3 }, + { x: 12, y: 3 }, + { x: 13, y: 3 }, + { x: 14, y: 3 }, + ], + "solo-east": [ + { x: 16, y: 5 }, + { x: 17, y: 5 }, + { x: 18, y: 5 }, + { x: 16, y: 7 }, + { x: 17, y: 7 }, + { x: 18, y: 7 }, + ], + "coaching-corner": [ + { x: 6, y: 9 }, + { x: 9, y: 9 }, + { x: 12, y: 9 }, + ], + "coffee-bar": [ + { x: 6, y: 1 }, + { x: 8, y: 1 }, + { x: 10, y: 1 }, + { x: 12, y: 1 }, + ], +}; + +// ── Helper: Get a position within a zone ──────────────────── +export function getZonePosition(zoneId, agentId, existingPositions = {}) { + const positions = ZONE_POSITIONS[zoneId] || [{ x: 0, y: 0 }]; + // Hash agentId to pick a deterministic slot + let hash = 0; + for (let i = 0; i < agentId.length; i++) { + hash = (hash * 31 + agentId.charCodeAt(i)) | 0; + } + const idx = Math.abs(hash) % positions.length; + // Avoid collisions: if slot is taken, find next available + const occupiedSet = new Set( + Object.entries(existingPositions) + .filter(([id]) => id !== agentId) + .map(([, pos]) => `${pos.x},${pos.y}`) + ); + + for (let i = 0; i < positions.length; i++) { + const pos = positions[(idx + i) % positions.length]; + if (!occupiedSet.has(`${pos.x},${pos.y}`)) { + return { ...pos }; + } + } + return { ...positions[idx] }; // fallback +} diff --git a/src/frontend/AgentSprite.jsx b/src/frontend/AgentSprite.jsx new file mode 100644 index 0000000..8b4a68e --- /dev/null +++ b/src/frontend/AgentSprite.jsx @@ -0,0 +1,190 @@ +import React, { useRef, useState } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Text, Html } from '@react-three/drei'; +import * as THREE from 'three'; + +// ── Isometric grid helper: convert grid coords → world position ── +// Isometric: x = (col - row) * tileW/2, z = (col + row) * tileH/2 +const TILE_W = 1.0; +const TILE_H = 0.5; + +export function gridToWorld(row, col) { + return [ + (col - row) * (TILE_W / 2), + 0, + (col + row) * (TILE_H / 2), + ]; +} + +// ── World pos to grid (inverse) ────────────────────────────────── +export function worldToGrid(x, z) { + const col = (x / (TILE_W / 2) + z / (TILE_H / 2)) / 2; + const row = (z / (TILE_H / 2) - x / (TILE_W / 2)) / 2; + return { row: Math.round(row), col: Math.round(col) }; +} + +// ── Activity → glow color ──────────────────────────────────────── +const ACTIVITY_COLORS = { + idle: '#44cc44', + thinking: '#ffcc00', + working: '#4488ff', + blocked: '#ff4444', + offline: '#666666', +}; + +const LERP_SPEED = 0.08; + +export default function AgentSprite({ agent, hovered, onHover }) { + const meshRef = useRef(); + const glowRef = useRef(); + const [targetPos, setTargetPos] = useState(null); + + // Initialize position + if (!targetPos && agent.position) { + setTargetPos(gridToWorld(agent.position.row, agent.position.col)); + } else if (agent.targetPosition) { + setTargetPos(gridToWorld(agent.targetPosition.row, agent.targetPosition.col)); + } + + useFrame((state, delta) => { + if (!meshRef.current || !agent) return; + + // Calculate target world position + const tgt = agent.targetPosition || agent.position; + if (!tgt) return; + + const [tx, , tz] = gridToWorld(tgt.row, tgt.col); + + // Smooth lerp toward target + meshRef.current.position.x += (tx - meshRef.current.position.x) * LERP_SPEED; + meshRef.current.position.z += (tz - meshRef.current.position.z) * LERP_SPEED; + + // If close enough, snap to target + if ( + Math.abs(tx - meshRef.current.position.x) < 0.01 && + Math.abs(tz - meshRef.current.position.z) < 0.01 + ) { + meshRef.current.position.x = tx; + meshRef.current.position.z = tz; + } + + // Floating bob + const bob = Math.sin(state.clock.elapsedTime * 2 + agent.position?.row || 0) * 0.05; + meshRef.current.position.y = 0.15 + bob; + + // Glow pulse based on activity + if (glowRef.current) { + const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.5 + 0.5; + glowRef.current.material.opacity = agent.activity === 'working' ? 0.3 + pulse * 0.4 : + agent.activity === 'thinking' ? 0.2 + pulse * 0.3 : + agent.activity === 'offline' ? 0 : + 0.15; + } + }); + + if (!agent) return null; + + const color = agent.activity === 'offline' ? '#666666' : agent.color; + const glowColor = ACTIVITY_COLORS[agent.activity] || '#44cc44'; + + return ( + + {/* Agent body — isometric cube */} + { + e.stopPropagation(); + onHover(agent.id, true); + }} + onPointerLeave={(e) => { + e.stopPropagation(); + onHover(agent.id, false); + }} + castShadow + > + + + + + {/* Glow ring below agent */} + + + + + + {/* Agent name label */} + + {agent.name} + + + {/* Hover tooltip */} + {hovered && ( + +
+
+ {agent.name} +
+
+ Status: {agent.activity} +
+ {agent.currentTask && ( +
+ {agent.currentTask} +
+ )} +
+ + )} +
+ ); +} diff --git a/src/frontend/WebSocketClient.js b/src/frontend/WebSocketClient.js new file mode 100644 index 0000000..3966c52 --- /dev/null +++ b/src/frontend/WebSocketClient.js @@ -0,0 +1,168 @@ +import useStore, { DEFAULT_POSITIONS } from './store.js'; + +// ── Mock activity cycle for demo mode (no backend) ──────────────── +const ACTIVITIES = ['idle', 'thinking', 'working', 'idle']; +const TASKS = [ + 'Memory maintenance', + 'Reviewing PR #42', + 'Running web search', + 'Pairing with vault', + 'Indexing documents', + 'Processing notification', + 'Training suggestion model', + 'Analyzing market data', +]; + +class WebSocketClient { + constructor() { + this.ws = null; + this.reconnectTimer = null; + this.reconnectDelay = 1000; + this.maxReconnectDelay = 30000; + this.url = 'ws://localhost:18790'; + this.mockInterval = null; + } + + connect() { + useStore.getState().setWsReconnecting(false); + + try { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log('[WS] Connected'); + this.reconnectDelay = 1000; + }; + + this.ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + this.handleMessage(msg); + } catch (e) { + console.error('[WS] Parse error:', e); + } + }; + + this.ws.onclose = (event) => { + console.log('[WS] Disconnected:', event.code, event.reason); + useStore.getState().setWsConnected(false); + this.scheduleReconnect(); + }; + + this.ws.onerror = (err) => { + console.error('[WS] Error:', err); + }; + } catch (e) { + console.error('[WS] Connection error:', e); + this.scheduleReconnect(); + } + } + + handleMessage(msg) { + switch (msg.type) { + case 'state.full': + useStore.getState().initFromSnapshot(msg.payload); + break; + + case 'state.diff': + useStore.getState().applyDiff(msg.changes); + break; + + case 'state.init': + // Backend sent initial state before WS connection + useStore.getState().initFromSnapshot(msg.payload || msg); + break; + + case 'heartbeat': + // Heartbeat received, connection is alive + break; + + default: + console.log('[WS] Unknown message type:', msg.type, msg); + } + } + + scheduleReconnect() { + if (this.reconnectTimer) return; + + useStore.getState().setWsReconnecting(true); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.reconnectDelay = Math.min( + this.reconnectDelay * 1.5, + this.maxReconnectDelay + ); + console.log(`[WS] Reconnecting in ${this.reconnectDelay}ms...`); + setTimeout(() => this.connect(), this.reconnectDelay); + }, 500); + } + + disconnect() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.stopMock(); + if (this.ws) { + this.ws.onclose = null; // prevent reconnect on intentional close + this.ws.close(); + this.ws = null; + } + } + + // ── Mock mode: when no backend is running, simulate agent activity ── + startMock() { + // Initialize with default agent state + useStore.getState().initFromSnapshot(null); + + // Cycle agents through activities + let cycle = 0; + const agentIds = Object.keys(DEFAULT_POSITIONS); + + this.mockInterval = setInterval(() => { + cycle++; + const store = useStore.getState(); + + // Every 3 seconds, pick 2 random agents to update + if (cycle % 3 === 0) { + const a1 = agentIds[Math.floor(Math.random() * agentIds.length)]; + let a2 = agentIds[Math.floor(Math.random() * agentIds.length)]; + while (a2 === a1 && agentIds.length > 1) { + a2 = agentIds[Math.floor(Math.random() * agentIds.length)]; + } + + const activity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)]; + const task = TASKS[Math.floor(Math.random() * TASKS.length)]; + + store.updateAgentActivity(a1, activity, task); + store.updateAgentActivity(a2, + ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)], + TASKS[Math.floor(Math.random() * TASKS.length)] + ); + } + + // Occasionally move agents + if (cycle % 10 === 0) { + const agentId = agentIds[Math.floor(Math.random() * agentIds.length)]; + const pos = store.agents[agentId]?.position; + if (pos) { + store.updateAgentPosition(agentId, { + row: Math.max(1, Math.min(18, pos.row + (Math.random() - 0.5) * 4)), + col: Math.max(1, Math.min(26, pos.col + (Math.random() - 0.5) * 6)), + }); + } + } + }, 1000); + } + + stopMock() { + if (this.mockInterval) { + clearInterval(this.mockInterval); + this.mockInterval = null; + } + } +} + +// Singleton +const wsClient = new WebSocketClient(); +export default wsClient; diff --git a/src/frontend/index.html b/src/frontend/index.html new file mode 100644 index 0000000..08bcbf3 --- /dev/null +++ b/src/frontend/index.html @@ -0,0 +1,40 @@ + + + + + + Spatial Office — Scobleclaw + + + +
+ + + diff --git a/src/frontend/main.jsx b/src/frontend/main.jsx new file mode 100644 index 0000000..6b238c4 --- /dev/null +++ b/src/frontend/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; + +const root = createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/src/frontend/store.js b/src/frontend/store.js new file mode 100644 index 0000000..8a88641 --- /dev/null +++ b/src/frontend/store.js @@ -0,0 +1,208 @@ +import { create } from 'zustand'; + +// ── Zone definitions ─────────────────────────────────────────────── +const ZONES = { + 'pod-a': { + id: 'pod-a', + name: 'Collaboration Pod A', + type: 'collaboration', + color: '#C68B3C', + bounds: { row: 2, col: 8, rows: 5, cols: 5 }, + }, + 'pod-b': { + id: 'pod-b', + name: 'Collaboration Pod B', + type: 'collaboration', + color: '#C68B3C', + bounds: { row: 2, col: 14, rows: 5, cols: 5 }, + }, + 'solo-1': { + id: 'solo-1', + name: 'Solo Work Area 1', + type: 'solo', + color: '#3A7CA5', + bounds: { row: 2, col: 0, rows: 10, cols: 6 }, + }, + 'solo-2': { + id: 'solo-2', + name: 'Solo Work Area 2', + type: 'solo', + color: '#3A7CA5', + bounds: { row: 2, col: 22, rows: 10, cols: 6 }, + }, + 'coaching': { + id: 'coaching', + name: 'Coaching Corner', + type: 'self-improvement', + color: '#5C8A5C', + bounds: { row: 13, col: 8, rows: 4, cols: 12 }, + }, + 'coffee': { + id: 'coffee', + name: 'Coffee Bar', + type: 'social', + color: '#8B6914', + bounds: { row: 0, col: 10, rows: 2, cols: 8 }, + }, +}; + +// ── Agent definitions ────────────────────────────────────────────── +const AGENT_DEFS = { + cos: { id: 'cos', name: 'COS', department: 'cos', color: '#FF6B35' }, + engineering: { id: 'engineering', name: 'Engineering', department: 'engineering', color: '#4ECDC4' }, + 'senior-eng': { id: 'senior-eng', name: 'Senior Eng', department: 'senior-eng', color: '#7B2D8E' }, + trust: { id: 'trust', name: 'Trust', department: 'trust', color: '#45B7D1' }, + market: { id: 'market', name: 'Market', department: 'market', color: '#96CEB4' }, + health: { id: 'health', name: 'Health', department: 'health', color: '#FFEAA7' }, + content: { id: 'content', name: 'Content', department: 'content', color: '#DDA0DD' }, + presentations: { id: 'presentations', name: 'Presentations', department: 'presentations', color: '#FF8A80' }, + vault: { id: 'vault', name: 'Vault', department: 'vault', color: '#A5D6A7' }, +}; + +// ── Default agent positions (row, col on the isometric grid) ─────── +const DEFAULT_POSITIONS = { + cos: { row: 3, col: 2 }, + engineering: { row: 4, col: 10 }, + 'senior-eng': { row: 5, col: 10 }, + trust: { row: 7, col: 2 }, + market: { row: 4, col: 16 }, + health: { row: 3, col: 24 }, + content: { row: 5, col: 16 }, + presentations: { row: 6, col: 16 }, + vault: { row: 7, col: 24 }, +}; + +// ── Helpers ──────────────────────────────────────────────────────── +function generateTileMap(gridRows, gridCols) { + const tiles = []; + for (let row = 0; row < gridRows; row++) { + tiles[row] = []; + for (let col = 0; col < gridCols; col++) { + tiles[row][col] = { type: 'floor', zoneId: findZoneForTile(row, col) }; + } + } + return { rows: gridRows, cols: gridCols, tiles }; +} + +function findZoneForTile(row, col) { + for (const zone of Object.values(ZONES)) { + const { row: zr, col: zc, rows, cols } = zone.bounds; + if (row >= zr && row < zr + rows && col >= zc && col < zc + cols) { + return zone.id; + } + } + return null; +} + +// ── Store ────────────────────────────────────────────────────────── +const useStore = create((set, get) => ({ + // Connection state + wsConnected: false, + wsReconnecting: false, + + // World state + grid: generateTileMap(20, 28), + zones: ZONES, + agents: {}, + + // Initialize from full state snapshot + initFromSnapshot(snapshot) { + const agents = {}; + for (const agentId of Object.keys(AGENT_DEFS)) { + const def = AGENT_DEFS[agentId]; + const pos = DEFAULT_POSITIONS[agentId]; + agents[agentId] = { + ...def, + position: { row: pos.row, col: pos.col }, + targetPosition: { row: pos.row, col: pos.col }, + activity: 'idle', + currentTask: null, + lastHeartbeat: Date.now(), + pulse: 0, + }; + } + // Apply snapshot overrides if provided + if (snapshot && snapshot.agents) { + for (const [id, data] of Object.entries(snapshot.agents)) { + if (agents[id]) { + Object.assign(agents[id], data); + if (data.position) { + agents[id].targetPosition = { ...data.position }; + } + } + } + } + set({ agents, wsConnected: true, wsReconnecting: false }); + }, + + // Apply a diff update + applyDiff(diffs) { + const state = get(); + const updated = { ...state.agents }; + + for (const diff of diffs) { + if (diff.op === 'agent.move' || diff.op === 'agent.update') { + const agentId = diff.path.split('.')[1]; // agents.engineering → engineering + if (updated[agentId]) { + const delta = diff.value || {}; + if (delta.position) { + delta.targetPosition = { ...delta.position }; + } + Object.assign(updated[agentId], delta); + } + } else if (diff.op === 'agent.position') { + const agentId = diff.path.split('.')[1]; + if (updated[agentId]) { + updated[agentId].targetPosition = { ...diff.value }; + } + } else if (diff.op === 'agent.activity') { + const agentId = diff.path.split('.')[1]; + if (updated[agentId]) { + updated[agentId] = { ...updated[agentId], ...diff.value }; + } + } + } + + set({ agents: updated }); + }, + + // WS connection status + setWsConnected(connected) { + set({ wsConnected: connected, wsReconnecting: !connected }); + }, + + setWsReconnecting(reconnecting) { + set({ wsReconnecting: reconnecting }); + }, + + // Update agent positions directly (used by mock mode) + updateAgentPosition(agentId, position) { + set(state => { + const agents = { ...state.agents }; + if (agents[agentId]) { + agents[agentId] = { + ...agents[agentId], + targetPosition: { ...position }, + }; + } + return { agents }; + }); + }, + + updateAgentActivity(agentId, activity, task) { + set(state => { + const agents = { ...state.agents }; + if (agents[agentId]) { + agents[agentId] = { + ...agents[agentId], + activity, + currentTask: task || agents[agentId].currentTask, + }; + } + return { agents }; + }); + }, +})); + +export { ZONES, AGENT_DEFS, DEFAULT_POSITIONS }; +export default useStore;