Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Catalog sync with upstream OpenAPI**: added `Weather Station` (deviceType `WeatherStation`, sensor with `atmosphericPressure` field), `Lock Vision` and `Lock Vision Pro` (video smart locks with the same lock/unlock/deadbolt safety semantics as Smart Lock Pro), the `Smart Lock Pro Wifi` Matter alias on the existing Smart Lock entry, and `uploadImage <imageUrl>` on AI Art Frame. The upload command parameter is documented as a single https URL pending upstream parameter-shape clarification.
- **LLM condition USD and token budgets**: per-rule and global `llm_budget` now accept `max_tokens_per_hour` (hourly window, aligned with `max_calls_per_hour`) and `max_cost_per_day_usd` (24h window). Costs are computed from per-model USD pricing in `src/llm/pricing.ts` and reported on `DecideResult.usage`. Audit entries for `llm-condition` now carry `llmUsage`, and `llm-budget-exceeded` records `budgetDimension` (`calls | tokens | cost`), `budgetLimit`, and `budgetObserved`. New lints: `condition-llm-tokens-budget-zero` (warns when token cap is 0) and `condition-llm-cost-without-known-model` (warns when a USD cap is set with `provider: auto` since the cost dimension silently skips models not in the pricing table).
- **Cross-event aggregation in conditions**: new `event_count` condition counts how many events fired for a given device inside a rolling time window. Schema:

```yaml
conditions:
- event_count:
device: front-door # deviceId or alias
event: motion.detected # optional canonical event filter
window: "5m" # duration: 100ms | 30s | 5m | 1h | 1d
min: 3 # required floor
max: 10 # optional ceiling
```

Backed by the same per-device JSONL ring at `~/.switchbot/device-history/<deviceId>.jsonl` used by `events history`, with rotation honored. The LLM-condition `recent_events` hook (declared in v0.2 schema since Track κ but unwired) now also pulls from this fetcher, populating `context.recent_events` with up to N most-recent matching events on the trigger device. Engine-level `LlmConditionEvaluator` is now wired into `RulesEngine` (previously only `simulate` had it). New lints: `condition-event-count-bad-window` and `condition-event-count-max-below-min`.
- **Local / non-tool-use LLM provider**: new `provider: local` for `llm` conditions points at any OpenAI-compatible chat completions endpoint (Ollama, llama.cpp server, vLLM, LM Studio). Defaults to `http://localhost:11434/v1`; override with `SWITCHBOT_LOCAL_LLM_URL`, `SWITCHBOT_LOCAL_LLM_MODEL`. Because most local servers don't support OpenAI-style tool use, `decide()` falls back to a structured-output prompt that asks for a `{"pass": bool, "reason": str}` JSON object and runs one repair retry if the first response is not parseable. Operators on tool-use-capable local endpoints can opt in via YAML `tool_use: true` or `SWITCHBOT_LOCAL_LLM_TOOL_USE=1`. New `LLMProvider.capabilities.toolUse` flag exposes this to the rest of the system. New `doctor` check `local-llm-reachable` probes the configured endpoint when (and only when) policy uses `provider: local`.
- **Daemon JSON-RPC IPC transport**: `switchbot rules run` now exposes a JSON-RPC 2.0 endpoint over a Unix domain socket on POSIX (`~/.switchbot/daemon.sock`, mode 0600) and a per-user named pipe on Windows (`\\.\pipe\switchbot-daemon-<user>`). v1 methods: `daemon.status`, `daemon.ping`, `daemon.reload`. New client at `src/daemon/client.ts` exposes `IpcDaemonClient.call()` and `.ping()`. Wire protocol: newline-delimited JSON-RPC. Sets the foundation for future `mcp serve --via-daemon` proxying so MCP clients can avoid per-call CLI cold-start. New `doctor` check `daemon-ipc` reports IPC reachability and round-trip latency when the daemon is running.

### Fixed

- **Daemon start failed in bundled builds** (BUG-001): CLI entry path resolution navigated above the dist/ directory when running from the single-file bundle. Now correctly detects the bundled scenario.
Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
- 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
- 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
- 🔍 **Dry-run mode** — preview every mutating request before it hits the API
- 🧪 **Fully tested** — 2391 Vitest tests, mocked axios, zero network in CI
- 🧪 **Fully tested** — 2465 Vitest tests, mocked axios, zero network in CI
- ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell

## Requirements
Expand Down Expand Up @@ -244,7 +244,8 @@ With a policy.yaml (v0.2) you can declare automations that the CLI
executes for you. Supported triggers: **MQTT** (device events),
**cron** (schedule-driven), and **webhook** (local HTTP POST).
Supported conditions: `time_between` (quiet hours), `device_state`
(live API check with per-tick dedup), and `llm` (AI decision — see
(live API check with per-tick dedup), `event_count` (rolling-window
counts over per-device history), and `llm` (AI decision — see
below). Every fire is recorded in `~/.switchbot/audit.log`. `rules run` is long-running; use
`daemon start` / `daemon reload` for the managed background mode.

Expand Down Expand Up @@ -272,14 +273,22 @@ then:
conditions:
- llm:
prompt: "Is the temperature above normal comfort range?"
provider: auto # auto | openai | anthropic
provider: auto # auto | openai | anthropic | local
cache_ttl: 5m
budget:
max_calls_per_hour: 20
max_tokens_per_hour: 100000 # optional rolling 1h token cap
max_cost_per_day_usd: 1.00 # optional rolling 24h USD cap
on_error: pass # fail | pass | skip
```

Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. `rules lint` flags misconfigured LLM conditions.
Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` for the cloud providers.
For `provider: local`, point `SWITCHBOT_LOCAL_LLM_URL` at any
OpenAI-compatible `/v1/chat/completions` endpoint (Ollama, llama.cpp,
vLLM, LM Studio); `SWITCHBOT_LOCAL_LLM_MODEL` picks the model and
`SWITCHBOT_LOCAL_LLM_TOOL_USE=1` opts into native tool-use when the
endpoint supports it (otherwise a structured-output fallback is used).
`rules lint` flags misconfigured LLM conditions.

**Decision trace** — set `automation.audit.evaluate_trace: sampled` (or `full`) in `policy.yaml` to record every evaluation decision.

Expand Down Expand Up @@ -633,7 +642,7 @@ switchbot doctor
switchbot doctor --json
```

Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, catalog-coverage, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, health, notify-connectivity, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation.
Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, catalog-coverage, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, daemon-ipc, health, notify-connectivity, local-llm-reachable, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. `daemon-ipc` round-trips the JSON-RPC socket when the daemon is running (silently skipped otherwise); `local-llm-reachable` only fires when policy uses `provider: local`. Use this to diagnose connectivity or config issues before running automation.

`--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating:

Expand Down Expand Up @@ -807,7 +816,7 @@ npm install

npm run dev -- <args> # Run from TypeScript sources via tsx
npm run build # Compile to dist/
npm test # Run the Vitest suite (2391 tests)
npm test # Run the Vitest suite (2465 tests)
npm run test:watch # Watch mode
npm run test:coverage # Coverage report (v8, HTML + text)
```
Expand Down
63 changes: 57 additions & 6 deletions docs/design/roadmap.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap — Phase 1 through Phase 4

> **Status as of 2026-05-06:** Phase 1 complete, Phase 2 complete,
> **Status as of 2026-05-15:** Phase 1 complete, Phase 2 complete,
> Phase 3A complete (keychain + install library + built-in CLI install
> command), Phase 3B tracked in the separate companion skill repo,
> Phase 4 shipped at v0.2 (rules engine with MQTT + cron +
Expand All @@ -10,7 +10,13 @@
> and `policy_diff`; v2.15.0 flips `policy new` default schema to v0.2
> and starts the v0.1 deprecation window.
> Tracks θ (notify actions) and η (LLM-backed rule suggestion)
> shipped in v3.0.
> shipped in v3.0. Track κ (AI decision loop — `rules trace`,
> `rules trace-explain`, `llm` conditions, `rules simulate`)
> shipped in v3.6.x. Track μ (catalog sync — Weather Station, Lock
> Vision, Lock Vision Pro, Smart Lock Pro Wifi alias, AI Art Frame
> `uploadImage`) and Track λ (USD/token budget, cross-event
> aggregation, local LLM providers, JSON-RPC IPC) are queued for
> the next release.
> Note: Track γ is a runtime capability increment on the v0.2 rule
> model, not a separate policy schema version.

Expand Down Expand Up @@ -196,13 +202,58 @@ the skill's `manifest.json` `roadmap` block, which points back here.
warning on provider failure. `rules_suggest` MCP tool gains a `llm`
parameter. All LLM calls are written to the audit log as
`kind: llm-suggest` with backend, model, and latency fields.
- **Track κ — AI decision loop *(shipped, v3.6.x)*.**
`rules trace` records every condition evaluation; `rules
trace-explain` renders why a tick fired or was blocked.
`conditions: [- llm: { prompt, provider, ... }]` lets a rule call
an LLM as a condition (per-condition + global call budget,
cache, on_error fail/pass/skip). `rules simulate` replays a rule
against `~/.switchbot/device-history` for offline what-if.
Audit gains `llm-condition`, `llm-cache-hit`,
`llm-budget-exceeded` records.

## In-flight (next release)

- **Track μ — catalog sync.**
Adds Weather Station (read-only sensor), Lock Vision and Lock Vision
Pro (video locks with `lock` / `unlock` / `deadbolt` for the Pro),
the `Smart Lock Pro Wifi` alias for Matter-enabled Lock Pro, and
`uploadImage` on AI Art Frame. Pure data + tests; no schema bump.
- **Track λ.1 — USD/token budget for `llm` conditions.**
`DecideResult.usage = { tokensIn, tokensOut, costUsd? }`; per-rule
`budget.max_tokens_per_hour` / `max_cost_per_day_usd` and global
`automation.llm_budget.{max_tokens_per_hour, max_cost_per_day_usd}`.
Audit `llm-budget-exceeded` carries `dimension: "calls" | "tokens" | "cost"`.
Pricing table at `src/llm/pricing.ts` (override via the policy
`automation.llm_pricing_overrides` field).
- **Track λ.2 — cross-event aggregation.**
Non-LLM `event_count: { device, event?, window, min, max? }`
condition counts firings inside a rolling time window. Same
`EventWindowFetcher` populates the LLM `recent_events` hook so
prompts get the last N events of the trigger device for free.
Backed by `~/.switchbot/device-history/<deviceId>.jsonl`.
- **Track λ.3 — local / non-tool-use LLM providers.**
`LLMProvider.capabilities.toolUse` flag gates a structured-output
fallback (JSON instruction + lenient parser + one repair retry)
for endpoints that don't support tool use. New `provider: local`
in policy points at any OpenAI-compatible `/v1/chat/completions`
(Ollama, llama.cpp, vLLM, LM Studio) via
`SWITCHBOT_LOCAL_LLM_URL`. `doctor` adds `local-llm-reachable`.
- **Track λ.4 — daemon JSON-RPC 2.0 IPC.**
`rules run` now exposes `daemon.status`, `daemon.ping`,
`daemon.reload` over a Unix domain socket
(`~/.switchbot/daemon.sock`, mode 0600) on POSIX or a per-user
named pipe (`\\.\pipe\switchbot-daemon-<user>`) on Windows. v1
surface; future `mcp serve --via-daemon` will proxy MCP tool calls
through the same transport. `doctor` adds `daemon-ipc`.

## Next execution queue (ordered)

1. **Daemon mode for repeated agent invocations.**
Add a local long-lived process with Unix socket / named pipe transport.
Exit when: repeated MCP + plan runs no longer pay fresh-process startup,
and `doctor` can verify daemon health.
1. **`mcp serve --via-daemon` proxy.**
Route MCP tool calls through the JSON-RPC IPC so repeated agent
invocations skip the cold-start cost.
Exit when: `mcp serve --via-daemon list_devices` round-trips and
`doctor` confirms daemon health.
2. **Standalone MCP package (`npx @switchbot/mcp-server`).**
Split MCP serve entrypoint into a tiny publishable package while
preserving tool contract parity with the main CLI.
Expand Down
58 changes: 54 additions & 4 deletions docs/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,27 +241,77 @@ too.
|-----------------|---------------------------------------------------------------|--------|
| `time_between` | `[HH:MM, HH:MM]` local-time window, `start > end` → overnight | active |
| `device_state` | `{ device, field, op, value }` read device status inline | active |
| `event_count` | Count events in a rolling window over device history | active |
| `all` | AND-join multiple sub-conditions | active |
| `any` | OR-join multiple sub-conditions | active |
| `not` | Negate a sub-condition | active |
| `llm` | AI judgement — prompt an LLM before firing (see below) | active |

**`event_count` condition fields:**

```yaml
conditions:
- event_count:
device: hallway-motion # alias or deviceId (required)
event: motion.detected # MQTT event name; omit to count all
window: "5m" # rolling window: \d+[smh] (required)
min: 3 # fire only if count >= min (required)
max: 10 # optional upper bound (count <= max)
```

Reads `~/.switchbot/device-history/<deviceId>.jsonl` (the same ring
buffer `events mqtt-tail` writes). Lints flag a missing history file
(`condition-event-count-no-history` — likely typo) and a `max < min`
inversion (`condition-event-count-max-below-min`). For the LLM-free
path: an "alarm if motion ≥3 times in 5m" guard does not need a model.

**LLM condition fields:**

```yaml
conditions:
- llm:
prompt: "Is the temperature above normal comfort range?"
provider: auto # auto | openai | anthropic
provider: auto # auto | openai | anthropic | local
timeout_ms: 5000 # 500–10000 (default 5000)
cache_ttl: 5m # none | \d+[smh] (default 5m)
recent_events: 5 # 0–20 (default 5) — recent events included in prompt
recent_events: 5 # 0–20 (default 5) — last N events of the
# trigger device included verbatim in prompt
budget:
max_calls_per_hour: 10 # per-condition limit (default 10)
max_calls_per_hour: 10 # per-condition limit (default 10)
max_tokens_per_hour: 100000 # optional rolling 1h token cap
max_cost_per_day_usd: 1.00 # optional rolling 24h USD cap
on_error: fail # fail | pass | skip (default fail)
```

Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. `rules lint` flags misconfigured LLM conditions. Global LLM budget can be set via `automation.llm_budget.max_calls_per_hour` (default 60).
Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` for the cloud providers.
For `provider: local`, point `SWITCHBOT_LOCAL_LLM_URL` at any
OpenAI-compatible `/v1/chat/completions` endpoint (Ollama defaults
to `http://localhost:11434/v1`, llama.cpp / vLLM / LM Studio also
work). Models without tool-use call into a structured-output
fallback (JSON instruction prompt + lenient parser + one repair
retry); set `SWITCHBOT_LOCAL_LLM_TOOL_USE=1` if your local model
does support tool use.

`rules lint` flags misconfigured LLM conditions, including
`condition-llm-tokens-budget-zero` (token cap set to 0 — never
allowed) and `condition-llm-cost-without-known-model` (cost cap on a
model not in the pricing table — won't be enforced).

The audit log records every LLM condition outcome as
`kind: llm-condition` with `usage: { tokensIn, tokensOut, costUsd? }`
and emits `kind: llm-budget-exceeded` with
`dimension: "calls" | "tokens" | "cost"` when a cap fires.

Global LLM budget (applied across all LLM conditions, in addition to
each condition's per-rule budget):

```yaml
automation:
llm_budget:
max_calls_per_hour: 60 # default 60
max_tokens_per_hour: 1000000 # optional
max_cost_per_day_usd: 10.00 # optional
```

**Destructive verbs are refused upstream.** The v0.2 validator
rejects `lock`, `unlock`, `deleteWebhook`, `deleteScene`,
Expand Down
Loading
Loading