From 42f661beb90851ec5785bdb975106c563059e3a3 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 12:03:45 +0000 Subject: [PATCH] Add hub package --- .gitignore | 13 ++ .smartconfig.json | 40 +++++ cli.js | 2 + cli.ts.js | 3 + html/index.html | 11 ++ package.json | 61 +++++++ readme.md | 3 + test/test.node.ts | 13 ++ ts/agents/classes.agentregistry.ts | 31 ++++ ts/approvals/classes.approvalqueue.ts | 57 +++++++ ts/audit/classes.auditlog.ts | 19 +++ ts/automations/classes.automationrunner.ts | 17 ++ ts/devices/classes.deviceregistry.ts | 113 +++++++++++++ ts/hub/classes.shxhub.ts | 139 ++++++++++++++++ ts/index.ts | 8 + ts/mcp/classes.mcpdescriptor.ts | 19 +++ ts/plugins.ts | 26 +++ ts/tools/classes.toolregistry.ts | 181 +++++++++++++++++++++ ts_web/index.ts | 8 + tsconfig.json | 13 ++ 20 files changed, 777 insertions(+) create mode 100644 .gitignore create mode 100644 .smartconfig.json create mode 100644 cli.js create mode 100644 cli.ts.js create mode 100644 html/index.html create mode 100644 package.json create mode 100644 readme.md create mode 100644 test/test.node.ts create mode 100644 ts/agents/classes.agentregistry.ts create mode 100644 ts/approvals/classes.approvalqueue.ts create mode 100644 ts/audit/classes.auditlog.ts create mode 100644 ts/automations/classes.automationrunner.ts create mode 100644 ts/devices/classes.deviceregistry.ts create mode 100644 ts/hub/classes.shxhub.ts create mode 100644 ts/index.ts create mode 100644 ts/mcp/classes.mcpdescriptor.ts create mode 100644 ts/plugins.ts create mode 100644 ts/tools/classes.toolregistry.ts create mode 100644 ts_web/index.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4336e0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +dist_*/ +dist_ts/ +coverage/ +.nyc_output/ +.nogit/ +.playwright-mcp/ +*.log +.DS_Store +.env +.env.* +!.env.example diff --git a/.smartconfig.json b/.smartconfig.json new file mode 100644 index 0000000..dbfb83c --- /dev/null +++ b/.smartconfig.json @@ -0,0 +1,40 @@ +{ + "@git.zone/cli": { + "projectType": "website", + "module": { + "githost": "code.foss.global", + "gitscope": "smarthome.exchange", + "gitrepo": "hub", + "description": "Local smarthome.exchange hub runtime.", + "npmPackagename": "@smarthome.exchange/hub", + "license": "MIT", + "projectDomain": "smarthome.exchange", + "keywords": [ + "smarthome.exchange", + "home automation", + "MCP", + "agents", + "typescript" + ] + }, + "release": { + "registries": ["https://verdaccio.lossless.digital"], + "accessLevel": "public" + } + }, + "@git.zone/tsbundle": { + "bundles": [ + { + "from": "./ts_web/index.ts", + "to": "./dist_serve/bundle.js", + "outputMode": "bundle", + "bundler": "esbuild", + "production": true, + "includeFiles": ["./html/index.html"] + } + ] + }, + "@git.zone/tswatch": { + "preset": "website" + } +} diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..ab546ce --- /dev/null +++ b/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import './dist_ts/index.js'; diff --git a/cli.ts.js b/cli.ts.js new file mode 100644 index 0000000..8f79934 --- /dev/null +++ b/cli.ts.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import '@git.zone/tsrun'; +import './ts/index.ts'; diff --git a/html/index.html b/html/index.html new file mode 100644 index 0000000..30bab70 --- /dev/null +++ b/html/index.html @@ -0,0 +1,11 @@ + + + + + + smarthome.exchange hub + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..1850768 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "@smarthome.exchange/hub", + "version": "0.1.0", + "private": false, + "description": "Local smarthome.exchange hub runtime.", + "main": "dist_ts/index.js", + "typings": "dist_ts/index.d.ts", + "type": "module", + "author": "Task Venture Capital GmbH", + "license": "MIT", + "scripts": { + "test": "pnpm run build && tstest test/ --verbose --logfile --timeout 60", + "build": "tsbuild tsfolders --web --allowimplicitany && tsbundle", + "watch": "tswatch", + "start": "node cli.js", + "startTs": "node cli.ts.js", + "buildDocs": "tsdoc" + }, + "dependencies": { + "@api.global/typedrequest": "^3.3.0", + "@api.global/typedrequest-interfaces": "^3.0.19", + "@api.global/typedserver": "^8.4.6", + "@api.global/typedsocket": "^4.1.2", + "@design.estate/dees-element": "^2.2.4", + "@ecobridge.xyz/devicemanager": "^3.1.0", + "@push.rocks/qenv": "^6.1.4", + "@push.rocks/smartpromise": "^4.2.4", + "@push.rocks/smartrx": "^3.0.10", + "@smarthome.exchange/agents": "workspace:*", + "@smarthome.exchange/catalog": "workspace:*", + "@smarthome.exchange/integrations": "workspace:*", + "@smarthome.exchange/interfaces": "workspace:*", + "@smarthome.exchange/sdk": "workspace:*" + }, + "devDependencies": { + "@git.zone/tsbuild": "^4.4.0", + "@git.zone/tsbundle": "^2.10.1", + "@git.zone/tsdoc": "^2.0.3", + "@git.zone/tsrun": "^2.0.3", + "@git.zone/tstest": "^3.6.3", + "@git.zone/tswatch": "^3.3.3", + "@push.rocks/projectinfo": "^5.1.0", + "@types/node": "^25.6.0" + }, + "files": [ + "ts/**/*", + "ts_web/**/*", + "dist/**/*", + "dist_*/**/*", + "dist_ts/**/*", + "dist_ts_web/**/*", + "assets/**/*", + "cli.js", + ".smartconfig.json", + "readme.md" + ], + "browserslist": [ + "last 1 chrome versions" + ], + "packageManager": "pnpm@10.28.2" +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b7d6409 --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +# @smarthome.exchange/hub + +Local runtime for device tools, scoped agents, automations, approvals, audit receipts, and the console API. diff --git a/test/test.node.ts b/test/test.node.ts new file mode 100644 index 0000000..1aba9ed --- /dev/null +++ b/test/test.node.ts @@ -0,0 +1,13 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ShxHub } from '../ts/index.js'; + +tap.test('creates a hub snapshot', async () => { + const hub = new ShxHub(); + await hub.start(); + const snapshot = hub.getSnapshot(); + expect(snapshot.devices.length).toBeGreaterThan(0); + expect(snapshot.agents.length).toEqual(6); + await hub.stop(); +}); + +export default tap.start(); diff --git a/ts/agents/classes.agentregistry.ts b/ts/agents/classes.agentregistry.ts new file mode 100644 index 0000000..34c5d42 --- /dev/null +++ b/ts/agents/classes.agentregistry.ts @@ -0,0 +1,31 @@ +import * as plugins from '../plugins.js'; + +export class AgentRegistry { + private agents = [...plugins.shxAgents.referenceAgents]; + private statuses: plugins.shxInterfaces.data.IAgentStatus[] = this.agents.map((agentArg) => ({ + agentId: agentArg.id, + actionsToday: 0, + runningToolIds: [], + })); + + public listAgents() { + return this.agents.filter((agentArg) => agentArg.enabled); + } + + public getAgentById(agentIdArg: string) { + return this.agents.find((agentArg) => agentArg.id === agentIdArg); + } + + public listStatuses() { + return [...this.statuses]; + } + + public recordAction(agentIdArg: string, latestArg: string) { + const status = this.statuses.find((statusArg) => statusArg.agentId === agentIdArg); + if (!status) { + return; + } + status.actionsToday++; + status.latest = latestArg; + } +} diff --git a/ts/approvals/classes.approvalqueue.ts b/ts/approvals/classes.approvalqueue.ts new file mode 100644 index 0000000..0bb96f2 --- /dev/null +++ b/ts/approvals/classes.approvalqueue.ts @@ -0,0 +1,57 @@ +import * as plugins from '../plugins.js'; +import type { AuditLog } from '../audit/classes.auditlog.js'; + +export class ApprovalQueue { + private approvals: plugins.shxInterfaces.data.IApprovalRequest[] = []; + + constructor(private auditLog: AuditLog) {} + + public createApproval(optionsArg: { + plan: plugins.shxInterfaces.data.IToolPlan; + call: plugins.shxInterfaces.data.IToolCall; + agentId: string; + title: string; + reason: string; + requestedScopes: plugins.shxInterfaces.data.TToolScope[]; + }) { + const approval: plugins.shxInterfaces.data.IApprovalRequest = { + id: `approval:${Date.now()}:${Math.random().toString(36).slice(2)}`, + planId: optionsArg.plan.id, + callId: optionsArg.call.id, + agentId: optionsArg.agentId, + title: optionsArg.title, + reason: optionsArg.reason, + confidence: optionsArg.plan.confidence, + requestedScopes: optionsArg.requestedScopes, + status: 'pending', + createdAt: new Date().toISOString(), + }; + this.approvals.unshift(approval); + return approval; + } + + public listApprovals(filterArg: { status?: plugins.shxInterfaces.data.IApprovalRequest['status'] } = {}) { + return this.approvals.filter((approvalArg) => !filterArg.status || approvalArg.status === filterArg.status); + } + + public submitApproval(approvalIdArg: string, decisionArg: 'approved' | 'rejected') { + const approval = this.approvals.find((approvalArg) => approvalArg.id === approvalIdArg); + if (!approval) { + throw new Error(`Approval not found: ${approvalIdArg}`); + } + if (approval.status !== 'pending') { + throw new Error(`Approval already decided: ${approvalIdArg}`); + } + approval.status = decisionArg; + approval.decidedAt = new Date().toISOString(); + const receipt = this.auditLog.appendReceipt({ + kind: 'approval', + callerId: approval.agentId, + approvalId: approval.id, + inputSummary: approval.title, + outputSummary: decisionArg, + reversible: false, + }); + return { approval, receipt }; + } +} diff --git a/ts/audit/classes.auditlog.ts b/ts/audit/classes.auditlog.ts new file mode 100644 index 0000000..f109928 --- /dev/null +++ b/ts/audit/classes.auditlog.ts @@ -0,0 +1,19 @@ +import * as plugins from '../plugins.js'; + +export class AuditLog { + private receipts: plugins.shxInterfaces.data.IAuditReceipt[] = []; + + public appendReceipt(receiptArg: Omit) { + const receipt: plugins.shxInterfaces.data.IAuditReceipt = { + id: `receipt:${Date.now()}:${Math.random().toString(36).slice(2)}`, + createdAt: new Date().toISOString(), + ...receiptArg, + }; + this.receipts.unshift(receipt); + return receipt; + } + + public listReceipts() { + return [...this.receipts]; + } +} diff --git a/ts/automations/classes.automationrunner.ts b/ts/automations/classes.automationrunner.ts new file mode 100644 index 0000000..f06b6df --- /dev/null +++ b/ts/automations/classes.automationrunner.ts @@ -0,0 +1,17 @@ +import * as plugins from '../plugins.js'; +import type { ToolRegistry } from '../tools/classes.toolregistry.js'; + +export class AutomationRunner { + public context: plugins.shxSdk.ShxAutomationContext; + + constructor(private toolRegistry: ToolRegistry) { + this.context = new plugins.shxSdk.ShxAutomationContext({ + callerId: 'hub:automation-runner', + executePlan: async (planArg: plugins.shxInterfaces.data.IToolPlan) => this.toolRegistry.executePlan(planArg), + }); + } + + public async start() {} + + public async stop() {} +} diff --git a/ts/devices/classes.deviceregistry.ts b/ts/devices/classes.deviceregistry.ts new file mode 100644 index 0000000..ddbf050 --- /dev/null +++ b/ts/devices/classes.deviceregistry.ts @@ -0,0 +1,113 @@ +import * as plugins from '../plugins.js'; + +export class DeviceRegistry { + private devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [ + { + id: 'climate.office', + name: 'Office Thermostat', + room: 'Office', + protocol: 'homeassistant', + manufacturer: 'Demo', + model: 'Climate v1', + online: true, + features: [ + { id: 'temperature', capability: 'sensor', name: 'Temperature', readable: true, writable: false, unit: 'C' }, + { id: 'target', capability: 'climate', name: 'Target temperature', readable: true, writable: true, unit: 'C' }, + ], + state: [ + { featureId: 'temperature', value: 22.4, updatedAt: new Date().toISOString() }, + { featureId: 'target', value: 22.4, updatedAt: new Date().toISOString() }, + ], + }, + { + id: 'lock.front', + name: 'Front Lock', + room: 'Hall', + protocol: 'matter', + manufacturer: 'Demo', + model: 'Lock v1', + online: true, + features: [ + { id: 'locked', capability: 'lock', name: 'Locked', readable: true, writable: true }, + ], + state: [ + { featureId: 'locked', value: false, updatedAt: new Date().toISOString() }, + ], + }, + { + id: 'energy.solar', + name: 'Solar Inverter', + room: 'Roof', + protocol: 'mqtt', + manufacturer: 'Demo', + model: 'PV v1', + online: true, + features: [ + { id: 'production', capability: 'energy', name: 'Production', readable: true, writable: false, unit: 'kW' }, + ], + state: [ + { featureId: 'production', value: 3.8, updatedAt: new Date().toISOString() }, + ], + }, + { + id: 'light.living.floor', + name: 'Living Floor Lamp', + room: 'Living', + protocol: 'zigbee', + manufacturer: 'Demo', + model: 'Light v1', + online: true, + features: [ + { id: 'on', capability: 'light', name: 'Power', readable: true, writable: true }, + { id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }, + ], + state: [ + { featureId: 'on', value: true, updatedAt: new Date().toISOString() }, + { featureId: 'brightness', value: 65, updatedAt: new Date().toISOString() }, + ], + }, + ]; + + public listDevices(filterArg: { room?: string; capability?: plugins.shxInterfaces.data.TDeviceCapability } = {}) { + return this.devices.filter((deviceArg) => { + const roomMatches = !filterArg.room || deviceArg.room === filterArg.room; + const capabilityMatches = !filterArg.capability || deviceArg.features.some((featureArg: plugins.shxInterfaces.data.IDeviceFeature) => featureArg.capability === filterArg.capability); + return roomMatches && capabilityMatches; + }); + } + + public getDeviceById(deviceIdArg: string) { + return this.devices.find((deviceArg) => deviceArg.id === deviceIdArg); + } + + public upsertDevices(devicesArg: plugins.shxInterfaces.data.IDeviceDefinition[]) { + for (const device of devicesArg) { + const existingIndex = this.devices.findIndex((existingDeviceArg) => existingDeviceArg.id === device.id); + if (existingIndex >= 0) { + this.devices[existingIndex] = device; + } else { + this.devices.push(device); + } + } + } + + public updateDeviceState(deviceIdArg: string, featureIdArg: string, valueArg: plugins.shxInterfaces.data.TDeviceStateValue) { + const device = this.getDeviceById(deviceIdArg); + if (!device) { + throw new Error(`Device not found: ${deviceIdArg}`); + } + const existingState = device.state.find((stateArg: plugins.shxInterfaces.data.IDeviceState) => stateArg.featureId === featureIdArg); + if (existingState) { + existingState.value = valueArg; + existingState.updatedAt = new Date().toISOString(); + return existingState; + } + const newState = { + featureId: featureIdArg, + value: valueArg, + updatedAt: new Date().toISOString(), + }; + device.state.push(newState); + return newState; + } +} diff --git a/ts/hub/classes.shxhub.ts b/ts/hub/classes.shxhub.ts new file mode 100644 index 0000000..2e9d9b0 --- /dev/null +++ b/ts/hub/classes.shxhub.ts @@ -0,0 +1,139 @@ +import * as plugins from '../plugins.js'; +import { AgentRegistry } from '../agents/classes.agentregistry.js'; +import { ApprovalQueue } from '../approvals/classes.approvalqueue.js'; +import { AuditLog } from '../audit/classes.auditlog.js'; +import { AutomationRunner } from '../automations/classes.automationrunner.js'; +import { DeviceRegistry } from '../devices/classes.deviceregistry.js'; +import { McpDescriptor } from '../mcp/classes.mcpdescriptor.js'; +import { ToolRegistry } from '../tools/classes.toolregistry.js'; + +export interface IShxHubOptions { + home?: Partial; +} + +export class ShxHub { + public typedrouter = new plugins.typedrequest.TypedRouter(); + public serviceQenv = new plugins.qenv.Qenv('./', './.nogit'); + + public home: plugins.shxInterfaces.data.IHomeDefinition; + public auditLog = new AuditLog(); + public approvalQueue = new ApprovalQueue(this.auditLog); + public deviceRegistry = new DeviceRegistry(); + public agentRegistry = new AgentRegistry(); + public toolRegistry = new ToolRegistry(this.deviceRegistry, this.agentRegistry, this.approvalQueue, this.auditLog); + public automationRunner = new AutomationRunner(this.toolRegistry); + public mcpDescriptor = new McpDescriptor(this.toolRegistry); + public integrationRegistry = plugins.shxIntegrations.createDefaultIntegrationRegistry(); + public integrationDiscoveryEngine = new plugins.shxIntegrations.DiscoveryEngine(this.integrationRegistry); + public integrationRuntimeManager = new plugins.shxIntegrations.IntegrationRuntimeManager(); + + private handlersRegistered = false; + + constructor(optionsArg: IShxHubOptions = {}) { + this.home = { + id: optionsArg.home?.id || 'home:birch-lane', + name: optionsArg.home?.name || 'Birch Lane', + address: optionsArg.home?.address || '14 Birch Lane, Berlin Mitte', + timezone: optionsArg.home?.timezone || 'Europe/Berlin', + members: optionsArg.home?.members || [ + { id: 'mira', name: 'Mira', initials: 'MK', present: true, locationLabel: 'Office' }, + { id: 'jonah', name: 'Jonah', initials: 'JH', present: true, locationLabel: 'Living' }, + { id: 'eli', name: 'Eli', initials: 'EL', present: false, locationLabel: 'School', eta: '15:40' }, + { id: 'noor', name: 'Noor', initials: 'NB', present: false, locationLabel: 'Out', eta: '19:10' }, + ], + rooms: optionsArg.home?.rooms || [ + { id: 'living', name: 'Living' }, + { id: 'kitchen', name: 'Kitchen' }, + { id: 'office', name: 'Office' }, + { id: 'hall', name: 'Hall' }, + { id: 'garage', name: 'Garage' }, + ], + }; + } + + public async start() { + this.registerTypedHandlers(); + await this.automationRunner.start(); + } + + public async stop() { + await this.automationRunner.stop(); + } + + public getSnapshot(): plugins.shxInterfaces.data.IHomeSnapshot { + return { + home: this.home, + devices: this.deviceRegistry.listDevices(), + agents: this.agentRegistry.listAgents(), + agentStatuses: this.agentRegistry.listStatuses(), + approvals: this.approvalQueue.listApprovals({ status: 'pending' }), + dashboards: [], + receipts: this.auditLog.listReceipts(), + createdAt: new Date().toISOString(), + }; + } + + public async discoverIntegrationCandidates(): Promise { + return this.integrationDiscoveryEngine.runActiveDiscovery({}); + } + + public async setupIntegration(domainArg: string, configArg: TConfig) { + const integration = this.integrationRegistry.get(domainArg) as plugins.shxIntegrations.BaseIntegration | undefined; + if (!integration) { + throw new Error(`Integration not found: ${domainArg}`); + } + const runtime = await this.integrationRuntimeManager.setupIntegration(integration, configArg, {}); + this.deviceRegistry.upsertDevices(await runtime.devices()); + return runtime; + } + + public registerTypedHandlers() { + if (this.handlersRegistered) { + return; + } + this.handlersRegistered = true; + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('listDevices', async (requestArg: plugins.shxInterfaces.request.IReq_ListDevices['request']) => ({ + devices: this.deviceRegistry.listDevices(requestArg), + })) + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('listAgents', async () => ({ + agents: this.agentRegistry.listAgents(), + statuses: this.agentRegistry.listStatuses(), + })) + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('listTools', async (requestArg: plugins.shxInterfaces.request.IReq_ListTools['request']) => ({ + tools: this.toolRegistry.listTools(requestArg), + })) + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('getHomeSnapshot', async () => ({ + snapshot: this.getSnapshot(), + })) + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('executeToolPlan', async (requestArg: plugins.shxInterfaces.request.IReq_ExecuteToolPlan['request']) => { + return this.toolRegistry.executePlan(requestArg.plan); + }) + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('listApprovals', async (requestArg: plugins.shxInterfaces.request.IReq_ListApprovals['request']) => ({ + approvals: this.approvalQueue.listApprovals(requestArg), + })) + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('submitApproval', async (requestArg: plugins.shxInterfaces.request.IReq_SubmitApproval['request']) => { + return this.approvalQueue.submitApproval(requestArg.approvalId, requestArg.decision); + }) + ); + } +} diff --git a/ts/index.ts b/ts/index.ts new file mode 100644 index 0000000..fc3f4f7 --- /dev/null +++ b/ts/index.ts @@ -0,0 +1,8 @@ +export * from './hub/classes.shxhub.js'; +export * from './devices/classes.deviceregistry.js'; +export * from './agents/classes.agentregistry.js'; +export * from './tools/classes.toolregistry.js'; +export * from './approvals/classes.approvalqueue.js'; +export * from './audit/classes.auditlog.js'; +export * from './automations/classes.automationrunner.js'; +export * from './mcp/classes.mcpdescriptor.js'; diff --git a/ts/mcp/classes.mcpdescriptor.ts b/ts/mcp/classes.mcpdescriptor.ts new file mode 100644 index 0000000..fe36f26 --- /dev/null +++ b/ts/mcp/classes.mcpdescriptor.ts @@ -0,0 +1,19 @@ +import type { ToolRegistry } from '../tools/classes.toolregistry.js'; + +export interface IMcpToolDescriptor { + name: string; + description: string; + inputSchema: Record; +} + +export class McpDescriptor { + constructor(private toolRegistry: ToolRegistry) {} + + public listMcpTools(): IMcpToolDescriptor[] { + return this.toolRegistry.listTools().map((toolArg) => ({ + name: toolArg.id, + description: toolArg.description, + inputSchema: toolArg.inputSchema, + })); + } +} diff --git a/ts/plugins.ts b/ts/plugins.ts new file mode 100644 index 0000000..261863f --- /dev/null +++ b/ts/plugins.ts @@ -0,0 +1,26 @@ +// Project scope +import * as shxAgents from '@smarthome.exchange/agents'; +import * as shxInterfaces from '@smarthome.exchange/interfaces'; +import * as shxIntegrations from '@smarthome.exchange/integrations'; +import * as shxSdk from '@smarthome.exchange/sdk'; + +export { shxAgents, shxInterfaces, shxIntegrations, shxSdk }; + +// @api.global scope +import * as typedrequest from '@api.global/typedrequest'; +import * as typedserver from '@api.global/typedserver'; +import * as typedsocket from '@api.global/typedsocket'; + +export { typedrequest, typedserver, typedsocket }; + +// @ecobridge.xyz scope +import * as deviceManager from '@ecobridge.xyz/devicemanager'; + +export { deviceManager }; + +// @push.rocks scope +import * as qenv from '@push.rocks/qenv'; +import * as smartpromise from '@push.rocks/smartpromise'; +import * as smartrx from '@push.rocks/smartrx'; + +export { qenv, smartpromise, smartrx }; diff --git a/ts/tools/classes.toolregistry.ts b/ts/tools/classes.toolregistry.ts new file mode 100644 index 0000000..b0680b0 --- /dev/null +++ b/ts/tools/classes.toolregistry.ts @@ -0,0 +1,181 @@ +import * as plugins from '../plugins.js'; +import type { AgentRegistry } from '../agents/classes.agentregistry.js'; +import type { ApprovalQueue } from '../approvals/classes.approvalqueue.js'; +import type { AuditLog } from '../audit/classes.auditlog.js'; +import type { DeviceRegistry } from '../devices/classes.deviceregistry.js'; + +export class ToolRegistry { + constructor( + private deviceRegistry: DeviceRegistry, + private agentRegistry: AgentRegistry, + private approvalQueue: ApprovalQueue, + private auditLog: AuditLog + ) {} + + public listTools(filterArg: { ownerId?: string } = {}) { + const deviceTools = this.deviceRegistry.listDevices().flatMap((deviceArg) => this.createDeviceTools(deviceArg)); + const agentTools = this.agentRegistry.listAgents().map((agentArg) => this.createAgentTool(agentArg)); + return [...deviceTools, ...agentTools].filter((toolArg) => !filterArg.ownerId || toolArg.ownerId === filterArg.ownerId); + } + + public async executePlan(planArg: plugins.shxInterfaces.data.IToolPlan): Promise { + const tools = this.listTools(); + const results: plugins.shxInterfaces.data.IToolExecutionResult[] = []; + + for (const call of planArg.calls) { + const tool = tools.find((toolArg) => toolArg.id === call.toolId); + if (!tool) { + results.push({ + callId: call.id, + status: 'failed', + errorMessage: `Tool not found: ${call.toolId}`, + }); + continue; + } + + if (tool.mode === 'ask') { + const approval = this.approvalQueue.createApproval({ + plan: planArg, + call, + agentId: tool.ownerId, + title: planArg.title, + reason: planArg.reason, + requestedScopes: tool.requiredScopes, + }); + results.push({ + callId: call.id, + status: 'queuedForApproval', + approvalId: approval.id, + }); + continue; + } + + if (tool.mode === 'suggest') { + results.push({ + callId: call.id, + status: 'suggested', + output: { + message: `${tool.name} returned a suggestion only.`, + }, + }); + continue; + } + + const output = this.executeAutoTool(tool, call); + this.auditLog.appendReceipt({ + kind: 'tool', + callerId: call.callerId, + toolId: tool.id, + scope: tool.requiredScopes.join(','), + inputSummary: JSON.stringify(call.input), + outputSummary: JSON.stringify(output), + reversible: tool.id.includes(':write'), + }); + results.push({ + callId: call.id, + status: 'executed', + output, + }); + } + + return { + planId: planArg.id, + results, + }; + } + + private createDeviceTools(deviceArg: plugins.shxInterfaces.data.IDeviceDefinition): plugins.shxInterfaces.data.IToolDefinition[] { + const tools: plugins.shxInterfaces.data.IToolDefinition[] = [ + { + id: `device:${deviceArg.id}:read`, + ownerId: deviceArg.id, + name: `Read ${deviceArg.name}`, + description: `Read current state from ${deviceArg.name}.`, + inputSchema: { type: 'object', properties: {} }, + requiredScopes: ['device.read'], + mode: 'auto', + }, + ]; + + for (const feature of deviceArg.features.filter((featureArg: plugins.shxInterfaces.data.IDeviceFeature) => featureArg.writable)) { + const mode = feature.capability === 'lock' ? 'ask' : 'auto'; + const scope = this.scopeForCapability(feature.capability, true); + tools.push({ + id: `device:${deviceArg.id}:${feature.id}:write`, + ownerId: deviceArg.id, + name: `Set ${deviceArg.name} ${feature.name}`, + description: `Write ${feature.name} on ${deviceArg.name}.`, + inputSchema: { + type: 'object', + properties: { + value: { type: ['string', 'number', 'boolean', 'object', 'null'] }, + }, + required: ['value'], + }, + requiredScopes: [scope], + mode, + }); + } + + return tools; + } + + private createAgentTool(agentArg: plugins.shxInterfaces.data.IAgentDefinition): plugins.shxInterfaces.data.IToolDefinition { + return { + id: `agent:${agentArg.id}:decide`, + ownerId: agentArg.id, + name: `Ask ${agentArg.name}`, + description: agentArg.role, + inputSchema: { + type: 'object', + properties: { + goal: { type: 'string' }, + scopes: { type: 'array', items: { type: 'string' } }, + }, + required: ['goal'], + }, + requiredScopes: ['agent.invoke'], + mode: agentArg.mode, + }; + } + + private executeAutoTool(toolArg: plugins.shxInterfaces.data.IToolDefinition, callArg: plugins.shxInterfaces.data.IToolCall) { + if (toolArg.id.startsWith('device:') && toolArg.id.endsWith(':read')) { + const deviceId = String(callArg.input.deviceId || toolArg.ownerId); + return { + device: this.deviceRegistry.getDeviceById(deviceId), + }; + } + + if (toolArg.id.startsWith('device:') && toolArg.id.endsWith(':write')) { + const [, deviceId, featureId] = toolArg.id.split(':'); + const state = this.deviceRegistry.updateDeviceState( + deviceId, + featureId, + callArg.input.value as plugins.shxInterfaces.data.TDeviceStateValue + ); + return { state }; + } + + if (toolArg.id.startsWith('agent:')) { + this.agentRegistry.recordAction(toolArg.ownerId, String(callArg.input.goal || 'Agent invoked')); + return { + message: `${toolArg.name} accepted the task.`, + }; + } + + return { + message: `${toolArg.name} executed.`, + }; + } + + private scopeForCapability(capabilityArg: plugins.shxInterfaces.data.TDeviceCapability, writeArg: boolean) { + const suffix = writeArg ? 'write' : 'read'; + if (capabilityArg === 'light') return `light.${suffix}` as plugins.shxInterfaces.data.TToolScope; + if (capabilityArg === 'climate') return `climate.${suffix}` as plugins.shxInterfaces.data.TToolScope; + if (capabilityArg === 'energy') return `energy.${suffix}` as plugins.shxInterfaces.data.TToolScope; + if (capabilityArg === 'lock') return `lock.${suffix}` as plugins.shxInterfaces.data.TToolScope; + if (capabilityArg === 'camera') return 'camera.read'; + return writeArg ? 'device.write' : 'device.read'; + } +} diff --git a/ts_web/index.ts b/ts_web/index.ts new file mode 100644 index 0000000..36b17ea --- /dev/null +++ b/ts_web/index.ts @@ -0,0 +1,8 @@ +import '@smarthome.exchange/catalog'; +import { html, render } from '@design.estate/dees-element'; + +const run = async () => { + render(html``, document.body); +}; + +run(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7862634 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "exclude": [ + "dist_*/**/*.d.ts" + ] +}