Metadata-Version: 2.4
Name: nucore-ai
Version: 2.2.0
Summary: Lightweight library for command/control/optimization/automation of smart home devices using LLMs
Author-email: Michel Kohanim <michel@universal-devices.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/NuCoreAI/nucore-ai.git
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests
Requires-Dist: openai
Requires-Dist: anthropic
Requires-Dist: httpx
Requires-Dist: websockets
Requires-Dist: udi_interface
Requires-Dist: python-dotenv
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Dynamic: license-file

# NuCoreAI Platform

## Goal

Convert natural language user queries into commands, queries, and programs for any NuCore-enabled platform (currently eisy).

## Quick Start

```shell
git clone https://github.com/NuCoreAI/nucore-ai.git
cd nucore-ai
python -m venv .venv && source .venv/bin/activate
pip install -e .
```

## Development

`pip install -e .` installs production dependencies only. To run the test suite, install the dev
extras instead:

```shell
pip install -e ".[dev]"
# or: pip install -r requirements-dev.txt
pytest
```

## Running the Unified Runtime

The entry point is the unified runtime: one system prompt, one native
tool-calling agentic loop, no router, no per-intent directory dispatch. It
executes user queries directly against a NuCore backend.

Create a runtime profile JSON first (see `src/unified/runtime_config.example.json`).

### Minimal (no backend)

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --query "Turn on the patio lights"
```

### With NuCore Backend (eisy)

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --backend-api-classpath iox.IoXWrapper \
  --backend-api-base-url https://192.168.6.134 \
  --backend-api-username admin \
  --backend-api-password yourpassword \
  --json-output true
```

### Interactive Mode

Omit `--query` to enter an interactive prompt loop:

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --backend-api-classpath iox.IoXWrapper \
  --backend-api-base-url https://192.168.6.134 \
  --backend-api-username admin \
  --backend-api-password yourpassword
```

### WebSocket Server Mode

Pass `--websocket-port` to run as a standalone WebSocket server instead of
`--query`/REPL mode -- no HTTP framework involved (uses the `websockets` package, already
a project dependency). Each connection gets its own session and conversation history;
every received message is treated as a query, and the response streams back over the
same connection.

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --backend-api-classpath iox.IoXWrapper \
  --backend-api-base-url https://192.168.6.134 \
  --backend-api-username admin \
  --backend-api-password yourpassword \
  --websocket-port 8765
```

This is a lower-level alternative to `eisy_ai`'s FastAPI-based chat server (a separate
sibling project) -- use this mode when a raw `ws://` endpoint is all you need, without
serving a browser UI.

By default the server binds TCP on `0.0.0.0` (all interfaces); pass `--websocket-host`
to bind a specific interface instead, e.g. `--websocket-host 127.0.0.1`.

#### Unix domain socket

Give `--websocket-host` a `unix://<path>` URI instead of an IP address to serve over a
Unix domain socket at `<path>` instead of TCP -- useful when a local reverse proxy or
supervisor should reach this process without exposing a TCP port. For example,
`--websocket-host unix:///tmp/ai-listener` serves on the socket file `/tmp/ai-listener`.
Any value that is neither a valid IP address nor a `unix://`-prefixed path is rejected
at startup -- there is no bare-path fallback, so a typo'd IP can't silently be treated
as a socket path. The `unix://` form is sufficient on its own to enter WebSocket server
mode -- `--websocket-port` is not required (and is ignored) in this mode. A stale socket
file already at that path is removed before binding, so a restart after a crash doesn't
fail with "address already in use".

The socket file is always created (and forced, regardless of umask or the parent
directory's group) owned by this process's own uid/primary gid with mode `0660`
(`rw-rw----`) -- readable/writable only by that same user or group, never by anyone
else.

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --backend-api-classpath iox.IoXWrapper \
  --backend-api-base-url https://192.168.6.134 \
  --backend-api-username admin \
  --backend-api-password yourpassword \
  --websocket-host unix:///var/run/nucore/unified.sock
```

Add `--websocket-client-id <uid>` to require that every connecting client's real
effective UID match `<uid>`; connections from any other UID are closed immediately
(WebSocket close code 1008), before any query is processed. The UID is read from the
kernel via `getpeereid()` (BSD/POSIX; this project targets FreeBSD/eisy, not Linux, so
`SO_PEERCRED` doesn't apply here) -- it can't be spoofed by the connecting client.
`--websocket-client-id` only has an effect in Unix socket mode; it's silently ignored
when `--websocket-host` is a TCP host/IP.

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --backend-api-classpath iox.IoXWrapper \
  --backend-api-base-url https://192.168.6.134 \
  --backend-api-username admin \
  --backend-api-password yourpassword \
  --websocket-host unix:///var/run/nucore/unified.sock \
  --websocket-client-id 1002
```

#### TLS (wss://)

Add `--ssl-certfile`/`--ssl-keyfile` (a PEM cert and private key, given together) to
serve `wss://` instead of `ws://` -- needed for clients that always connect over TLS
(e.g. the Eisy UI). Uses Python's standard-library `ssl` module, no extra dependency.

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --backend-api-classpath iox.IoXWrapper \
  --backend-api-base-url https://192.168.6.134 \
  --backend-api-username admin \
  --backend-api-password yourpassword \
  --websocket-port 8765 \
  --ssl-certfile secrets/cert.pem \
  --ssl-keyfile secrets/key.pem
```

Generate a self-signed pair for local/dev use with:

```shell
openssl req -x509 -newkey rsa:2048 -keyout secrets/key.pem -out secrets/cert.pem \
  -days 825 -nodes -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
```

`secrets/` is already gitignored. A self-signed cert will trigger a browser warning
unless the client is configured to skip verification.

### Secrets File

Use `--secrets-file` to provide API keys as key/value pairs. These values are
loaded into a dict and passed to provider dispatch as the environment source.

The file must be valid JSON with a single top-level object. Each property name
is a secret name and each value is the string to use for lookup. Do not use
shell syntax, comments, or nested structures.

Example `secrets.json`:

```json
{
  "OPENAI_API_KEY": "...",
  "ANTHROPIC_API_KEY": "...",
  "GEMINI_API_KEY": "...",
  "XAI_API_KEY": "...",
  "LLAMACPP_API_KEY": "..."
}
```

Format rules:

- Top level must be a JSON object.
- Keys should be environment-style secret names such as `OPENAI_API_KEY`.
- Values should be strings.
- Duplicate or alias keys are allowed if you want to point multiple names at the same secret value.

Usage:

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --secrets-file /path/to/secrets.json \
  --query "Turn on the patio lights"
```

### Logging

The runtime supports centralized, flexible logging for both development and production use. When
console logging is on (the default), `DEBUG`/`INFO` records go to stdout and `WARNING`/`ERROR`
records go to stderr -- so `2>/dev/null` silences errors/warnings while keeping normal output, and
piping just stdout to a log aggregator won't miss error-level records mixed in. The optional log
file (`--log-file`/`NUCORE_LOG_FILE`) always receives every level, regardless of the console split.

#### Runtime Logging Flags

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --log-level DEBUG \
  --log-file logs/unified-runtime.log
```

Use JSON logs for ingestion by external tools:

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --log-json \
  --log-file logs/unified-runtime.json.log
```

Disable console logs (for quiet batch or service environments):

```shell
python -m unified.run_unified_runtime \
  --runtime-config src/unified/runtime_config.example.json \
  --no-log-console \
  --log-file logs/unified-runtime.log
```

#### Logging Environment Variables

- `NUCORE_LOG_LEVEL` (default: `INFO`)
- `NUCORE_LOG_JSON` (`true`/`false`, default: `false`)
- `NUCORE_LOG_FILE` (optional file path)
- `NUCORE_LOG_CONSOLE` (`true`/`false`, default: `true`)

#### Logger Usage in Code

```python
from utils import configure_logging, get_logger

configure_logging(level="INFO")
logger = get_logger(__name__)
logger.info("runtime started")
```

### Full CLI Reference

| Flag | Description |
|---|---|
| `--runtime-config` | Required path to JSON with top-level `nucore_runtime` |
| `--secrets-file` | Optional JSON file of secret key/value pairs passed into provider client key resolution |
| `--query` | Single query mode; omit for interactive loop |
| `--websocket-port` | Run as a native WebSocket server on this port instead of `--query`/REPL mode. Ignored when `--websocket-host` is a Unix socket path |
| `--websocket-host` | IP address to bind the WebSocket server to over TCP (default `0.0.0.0`), or a `unix://<path>` URI to serve over a Unix domain socket at `<path>` instead -- on its own (without `--websocket-port`) it's enough to enter WebSocket server mode. Any other value (including a bare filesystem path with no `unix://` prefix) is rejected |
| `--websocket-client-id` | Unix socket mode only: required effective UID (checked via `getpeereid()`) of the connecting client; other UIDs are rejected. Ignored when `--websocket-host` is a TCP host/IP |
| `--ssl-certfile` | PEM cert file; with `--ssl-keyfile`, serves `--websocket-port` over `wss://` |
| `--ssl-keyfile` | PEM private key file; with `--ssl-certfile`, serves `--websocket-port` over `wss://` |
| `--backend-api-classpath` | Python class path for backend API (e.g. `iox.IoXWrapper`) |
| `--backend-api-base-url` | Base URL for backend API (`http(s)://host:port`, or `unix:///path/to/socket` for `iox.IoXWrapper` to connect over a Unix domain socket instead of TCP) |
| `--backend-api-username` | Backend API username |
| `--backend-api-password` | Backend API password |
| `--json-output` | Enable JSON output mode for backend API |
| `--prompt_type` | Prompt variant to use (e.g. `shared-features`) |
| `--log-level` | Logging level override: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
| `--log-file` | Optional rotating log file path |
| `--log-json` | Emit logs in JSON format |
| `--no-log-console` | Disable console logging |
| `--stream` / `--no-stream` | Force LLM token streaming on/off for every `nucore_runtime` profile, overriding each profile's own `stream` setting |
| `--max-iterations` | Override the agentic loop's max tool-call iterations per query (defaults to runtime config's `max_iterations`, or 8) |
| `--preferences-dir` | Directory for this installation's customer preferences (aliases/events); overrides runtime config's `preferences_dir` -- no default, preferences are unavailable without one |
| `--diagnostic-step` | Bypass the LLM/agentic loop and call one diagnostic tool directly against a live backend (e.g. `get_full_system_config`); prints the raw result and exits. Pairs with `--diagnostic-params`. Manual testing only |
| `--diagnostic-params` | JSON object of keyword params for `--diagnostic-step` |

## Supported Providers

| Provider | Alias | Env Var |
|---|---|---|
| Anthropic Claude | `claude`, `anthropic` | `ANTHROPIC_API_KEY` |
| OpenAI | `openai` | `OPENAI_API_KEY` |
| Google Gemini | `gemini`, `google` | `GEMINI_API_KEY` |
| xAI Grok | `grok`, `xai` | `XAI_API_KEY` |
| llama.cpp (local) | `llama.cpp`, `llamacpp` | `LLAMACPP_API_KEY` (optional) |

Provider and model settings come from the runtime profile file passed to `--runtime-config`. Profiles use a `provider` field and do not rely on legacy `llm` aliases or `supported_llms` fallback behavior. API keys can be embedded in the profile, supplied via `--secrets-file`, or read from process environment variables.

Ready-to-copy example profiles: `src/unified/runtime_config.example.json` (Claude, the default),
`src/unified/runtime_config.openai.example.json`, `src/unified/runtime_config.grok.example.json`.
Copy whichever one you want over your live `runtime_config.json` (or pass it directly via
`--runtime-config`) and set that provider's env var.

A profile can also set `"reasoning_effort"` (e.g. `"none"`/`"low"`/`"medium"`/`"high"`), forwarded
to OpenAI-compatible providers only when present. Some reasoning-tier models reject function tools
on `/v1/chat/completions` entirely unless this is explicitly set -- see the OpenAI example profile.

## Using a Local (Edge) LLM with llama.cpp

### Build llama.cpp

```shell
sudo apt install build-essential cmake clang libomp-dev libcurl4-openssl-dev
```

#### CPU only

```shell
cmake -B build.cpu
cmake --build build.cpu --config release
```

#### Nvidia GPU

```shell
sudo ubuntu-drivers install
sudo apt install nvidia-cuda-toolkit
cmake -B build.cuda -DGGML_CUDA=on
cmake --build build.cuda --config release
```

### Start the Server

```shell
build.cuda/bin/llama-server \
  -m /path/to/model.gguf \
  -c 64000 --port 8013 --host 0.0.0.0 \
  -t 15 --n-gpu-layers 50 --batch-size 8192
```

### Connect the Runtime to llama.cpp

```shell
python -m unified.run_unified_runtime \
  --runtime-config /path/to/nucore_runtime.json \
  --backend-api-classpath iox.IoXWrapper \
  --backend-api-base-url https://192.168.6.134 \
  --backend-api-username admin \
  --backend-api-password yourpassword
```

Runtime profile for llama.cpp (`--runtime-config` target):

```json
{
  "nucore_runtime": {
    "default": {
      "provider": "llama.cpp",
      "model": "qwen3-instruct",
      "url": "http://192.168.6.113:8013/v1",
      "max_turns": 20,
      "temperature": 0.2,
      "max_tokens": 32000
    }
  }
}
```

## Capabilities

Beyond device/group/routine/variable command-and-control, the unified runtime supports:

- **Diagnostics** -- stateless, no session or start call: `get_diagnostics_prompt` fetches
  reference material (PLM/device link records, the DEVICE ACTIVITY LOG format and how to read it
  for past behavior, known fixes) on demand for the current question only -- like consulting
  DEVICE DATABASE/ROUTINES DATABASE, it never turns into a standing mode and doesn't carry over to
  a later, unrelated question in the same conversation. `run_diagnostic_step` then runs one
  diagnostic step directly against the backend (e.g. checking or starting/stopping/restarting core
  services), and `run_shell_command` runs a shell command on the backend host (e.g. to
  search the device activity log).
- **Plan** -- `start_plan`/`run_plan_step` walk a customer through a structured multi-step task
  such as a new device installation, rather than a single command/response turn.
- **User preferences** -- `preference_op`/`list_preferences` store per-user aliases (e.g. naming
  a device or routine) and event subscriptions, persisted across sessions.
- **Plugin management** -- `list_store_plugins`/`list_purchased_plugins`/`list_installed_plugins`
  cover NuCore's plugin marketplace (browse/license/installed state); `get_plugin_capabilities`/
  `call_plugin` let the model extend its own capabilities with a plugin's tools when no
  built-in tool covers a request. `install_plugin`/`buy_plugin`/`delete_plugin` don't complete
  anything themselves -- for security reasons, installing, purchasing, and deleting all happen on
  the web -- each returns a link for the customer to finish there. `plugin_ops` starts, stops, or
  restarts an installed plugin's own service -- the only way to do that; core services go through
  the diagnostics flow above instead.

See `src/unified/prompt/definitions.md` for the exact tool-selection rules the model follows for
each of these, and `src/unified/README.md` for the tool/handler layout.

## Hardware

Tested with [eisy](https://www.universal-devices.com/product/eisy-home-r2/).

## Further Documentation

- Unified runtime architecture and tool reference: `src/unified/README.md`

