Files
Maven/internal/store/tools.go
T
kami dc4c5b7841 Read and control the house through Home Assistant (#256)
A `smarthome` block points Maven at a Home Assistant instance. She reads its
entity states to answer "что включено дома?", and every controllable device
becomes a PROPOSED row in the existing act allowlist — cmd
["smarthome",<entity_id>,<service>], scope smarthome:<domain> — so nothing new
had to be invented for the mutating half. ProposeTool/EnableTool/DisableTool,
tool.Matcher and the confirm turn are untouched; one branch in Executor.Exec
routes such a row to the client instead of exec, and "smarthome" is never run as
a binary. This is the same trick overnight/mcp-tools used for #251, on purpose.

Discovery only ever PROPOSES, and every control row is destructive=true: there
is no read-only way to turn the heating off, so flipping something in his flat
always costs a confirm turn and always had to be enabled by hand on /tools,
behind step-up.

The entity and the service come from the row he enabled, never from the
utterance — Exec drops the spoken tail for a house row. A router that misheard
can pick the wrong lamp; it cannot compose a target of its own. The service is
checked against the domain's table on the way out too, so a hand-edited cmd
column cannot reach an arbitrary Home Assistant service. set_brightness and
set_temperature are deliberately absent: a spoken number the router got wrong is
a wrong act on real hardware, and on/off is the whole of what a voice turn can
defend.

The read side is a query source ("home", before calendar and the recall passes)
so "что нового дома?" is not answered from an old note. Its matcher needs a
house marker plus an ask plus a device word and bails out on weather wording,
because "какая температура на улице?" belongs to the weather source.

Off unless configured: the block is dark without "enabled": true, and
applyDefaults normalises a disabled block to nil so "off" stays in one place.
deploy/mavend.json carries it disabled, with the token as ${HA_TOKEN}.

NOT shipped, and not faked: MQTT / Zigbee2MQTT (plan steps 2 and 5) and the
sensor-to-fact and presence-probe pipelines. There is no broker and no Home
Assistant anywhere on this network — 8123 and 1883 are closed on every host in
192.168.1.0/24 — the module tree is vendored so a paho dependency cannot be
added offline, and Home Assistant already fronts Zigbee2MQTT where it exists.
Writing a sensor pipeline with no sensor to test it against would be a guess.

Vikunja #256
2026-08-01 06:27:39 +04:00

222 lines
7.9 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// Tool — one act in the allowlist. Scope namespaces tools (e.g. "homelab").
// Cmd is the fixed argv prefix run with the utterance's args appended (no
// shell). Status 'proposed' is a scaffold that drives nothing; 'enabled' is
// the human-flipped, runnable form.
type Tool struct {
Name string
Scope string
Cmd []string
Destructive bool
Status string // proposed | enabled
Utterance string // provenance: the utterance that scaffolded a proposal
CreatedTs time.Time
UpdatedTs time.Time
}
var (
// ErrToolNotFound — no tool row with this name.
ErrToolNotFound = errors.New("store: tool not found")
// ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something).
ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd")
)
// ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance)
// if no row for name exists yet. Returns true when a new proposal was written,
// false when a row (proposed or enabled) already existed. maven calls this when
// she classifies an act whose verb isn't on the enabled allowlist — she drafts
// the registration; a human enables it. Never overwrites an enabled tool.
// scope defaults to "homelab" when empty.
func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
if scope == "" {
scope = "homelab"
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?)
ON CONFLICT(name) DO NOTHING`,
name, scope, utterance, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return false, fmt.Errorf("propose tool: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("propose tool: rows affected: %w", err)
}
return n > 0, nil
}
// ProposeMCPTool is ProposeTool for a tool discovered on an MCP server
// (Vikunja #251): the proposal already knows what it would run, so cmd and
// destructive are written with it and Kami only has to press enable.
//
// It is still a PROPOSAL. Discovery cannot grant a capability — that is the
// whole reason a server can be configured without its tools becoming live.
// Like ProposeTool it never touches an existing row, so re-discovery on every
// restart is idempotent and cannot silently re-arm a tool that was disabled or
// change the cmd of one already enabled.
func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []string, destructive bool, utterance string, ts time.Time) (bool, error) {
if len(cmd) == 0 {
return false, ErrToolCmd
}
if scope == "" {
scope = "homelab"
}
raw, err := json.Marshal(cmd)
if err != nil {
return false, fmt.Errorf("propose mcp tool: %w", err)
}
d := 0
if destructive {
d = 1
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, ?, ?, 'proposed', ?, ?, ?)
ON CONFLICT(name) DO NOTHING`,
name, scope, string(raw), d, utterance, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return false, fmt.Errorf("propose mcp tool: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("propose mcp tool: rows affected: %w", err)
}
return n > 0, nil
}
// ProposeSmartHomeTool is ProposeTool for a controllable device discovered on
// the Home Assistant instance (Vikunja #256). Like ProposeMCPTool the proposal
// already knows what it would run, so cmd is written with it and Kami only has
// to press enable.
//
// It is still a PROPOSAL, and destructive is not a parameter: there is no
// read-only way to turn a lamp off, so every house row carries the confirm
// turn. Re-discovery on every refresh is idempotent — an existing row is never
// touched, so a device he disabled stays disabled.
func (s *Store) ProposeSmartHomeTool(ctx context.Context, name, scope string, cmd []string, utterance string, ts time.Time) (bool, error) {
return s.ProposeMCPTool(ctx, name, scope, cmd, true, utterance, ts)
}
// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the
// human "enable" act (the authed surface calls it); it upserts so enabling a
// name that was never proposed still works. An empty cmd is refused — an
// enabled tool that runs nothing is a footgun, not a tool.
// scope defaults to "homelab" when empty.
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
if len(cmd) == 0 {
return ErrToolCmd
}
if scope == "" {
scope = "homelab"
}
raw, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("enable tool: %w", err)
}
d := 0
if destructive {
d = 1
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, ?, ?, 'enabled', '', ?, ?)
ON CONFLICT(name) DO UPDATE SET scope=excluded.scope, cmd=excluded.cmd, destructive=excluded.destructive,
status='enabled', updated_ts=excluded.updated_ts`,
name, scope, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return fmt.Errorf("enable tool: %w", err)
}
return nil
}
// DeleteTool permanently removes a tool row. Used for "dismiss" on proposed
// tools — there's no dismissed status, the proposal is simply gone and maven
// can re-propose it later if the same gap is encountered. Idempotent: deleting
// a tool that doesn't exist is a no-op.
func (s *Store) DeleteTool(ctx context.Context, name string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM tools WHERE name = ?`, name)
return err
}
// DisableTool sets a tool's status from 'enabled' back to 'proposed'. This is
// the "disable" act on the authed surface — the tool stays in the store (its
// provenance preserved) but won't run until re-enabled. Idempotent: disabling
// a tool that is already proposed or doesn't exist is a no-op.
func (s *Store) DisableTool(ctx context.Context, name string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE tools SET status = 'proposed', updated_ts = ? WHERE name = ? AND status = 'enabled'`,
time.Now().UnixMilli(), name)
if err != nil {
return fmt.Errorf("disable tool: %w", err)
}
return nil
}
// LookupTool returns the tool by name. ErrToolNotFound when absent.
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
row := s.db.QueryRowContext(ctx, `
SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts
FROM tools WHERE name = ?`, name)
t, err := scanTool(row)
if errors.Is(err, sql.ErrNoRows) {
return Tool{}, ErrToolNotFound
}
return t, err
}
// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
q := `SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
var args []any
if status != "" {
q += ` WHERE status = ?`
args = append(args, status)
}
q += ` ORDER BY name ASC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list tools: %w", err)
}
defer rows.Close()
var out []Tool
for rows.Next() {
t, err := scanTool(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// scanner is the shared shape of *sql.Row and *sql.Rows.
type scanner interface{ Scan(...any) error }
func scanTool(sc scanner) (Tool, error) {
var t Tool
var cmdJSON string
var d int
var created, updated int64
if err := sc.Scan(&t.Name, &t.Scope, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
return Tool{}, err
}
if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil {
return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err)
}
t.Destructive = d != 0
t.CreatedTs = time.UnixMilli(created).UTC()
t.UpdatedTs = time.UnixMilli(updated).UTC()
return t, nil
}