Add hub package
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
dist_ts/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.nogit/
|
||||
.playwright-mcp/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
import '@git.zone/tsrun';
|
||||
import './ts/index.ts';
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>smarthome.exchange hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @smarthome.exchange/hub
|
||||
|
||||
Local runtime for device tools, scoped agents, automations, approvals, audit receipts, and the console API.
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export class AuditLog {
|
||||
private receipts: plugins.shxInterfaces.data.IAuditReceipt[] = [];
|
||||
|
||||
public appendReceipt(receiptArg: Omit<plugins.shxInterfaces.data.IAuditReceipt, 'id' | 'createdAt'>) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<plugins.shxInterfaces.data.IHomeDefinition>;
|
||||
}
|
||||
|
||||
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<plugins.shxIntegrations.IDiscoveryCandidate[]> {
|
||||
return this.integrationDiscoveryEngine.runActiveDiscovery({});
|
||||
}
|
||||
|
||||
public async setupIntegration<TConfig>(domainArg: string, configArg: TConfig) {
|
||||
const integration = this.integrationRegistry.get(domainArg) as plugins.shxIntegrations.BaseIntegration<TConfig> | 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<plugins.shxInterfaces.request.IReq_ListDevices>(
|
||||
new plugins.typedrequest.TypedHandler('listDevices', async (requestArg: plugins.shxInterfaces.request.IReq_ListDevices['request']) => ({
|
||||
devices: this.deviceRegistry.listDevices(requestArg),
|
||||
}))
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler<plugins.shxInterfaces.request.IReq_ListAgents>(
|
||||
new plugins.typedrequest.TypedHandler('listAgents', async () => ({
|
||||
agents: this.agentRegistry.listAgents(),
|
||||
statuses: this.agentRegistry.listStatuses(),
|
||||
}))
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler<plugins.shxInterfaces.request.IReq_ListTools>(
|
||||
new plugins.typedrequest.TypedHandler('listTools', async (requestArg: plugins.shxInterfaces.request.IReq_ListTools['request']) => ({
|
||||
tools: this.toolRegistry.listTools(requestArg),
|
||||
}))
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler<plugins.shxInterfaces.request.IReq_GetHomeSnapshot>(
|
||||
new plugins.typedrequest.TypedHandler('getHomeSnapshot', async () => ({
|
||||
snapshot: this.getSnapshot(),
|
||||
}))
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler<plugins.shxInterfaces.request.IReq_ExecuteToolPlan>(
|
||||
new plugins.typedrequest.TypedHandler('executeToolPlan', async (requestArg: plugins.shxInterfaces.request.IReq_ExecuteToolPlan['request']) => {
|
||||
return this.toolRegistry.executePlan(requestArg.plan);
|
||||
})
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler<plugins.shxInterfaces.request.IReq_ListApprovals>(
|
||||
new plugins.typedrequest.TypedHandler('listApprovals', async (requestArg: plugins.shxInterfaces.request.IReq_ListApprovals['request']) => ({
|
||||
approvals: this.approvalQueue.listApprovals(requestArg),
|
||||
}))
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler<plugins.shxInterfaces.request.IReq_SubmitApproval>(
|
||||
new plugins.typedrequest.TypedHandler('submitApproval', async (requestArg: plugins.shxInterfaces.request.IReq_SubmitApproval['request']) => {
|
||||
return this.approvalQueue.submitApproval(requestArg.approvalId, requestArg.decision);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ToolRegistry } from '../tools/classes.toolregistry.js';
|
||||
|
||||
export interface IMcpToolDescriptor {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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<plugins.shxInterfaces.data.IToolPlanResult> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import '@smarthome.exchange/catalog';
|
||||
import { html, render } from '@design.estate/dees-element';
|
||||
|
||||
const run = async () => {
|
||||
render(html`<shx-console-shell></shx-console-shell>`, document.body);
|
||||
};
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user