neoagent 2.4.1-beta.28 → 2.4.1-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const { getConnectionAccessMode } = require('../access');
4
+ const {
5
+ HOME_ASSISTANT_APP,
6
+ HOME_ASSISTANT_TOOL_DEFINITIONS,
7
+ } = require('./constants');
8
+
9
+ function summarizeAccountRow(row, envStatus) {
10
+ if (!envStatus.configured) {
11
+ return {
12
+ id: row?.id || null,
13
+ status: 'env_not_configured',
14
+ connected: false,
15
+ accountEmail: row?.account_email || null,
16
+ lastConnectedAt: row?.last_connected_at || null,
17
+ accessMode: 'read_write',
18
+ };
19
+ }
20
+
21
+ if (!row) {
22
+ return {
23
+ id: null,
24
+ status: 'not_connected',
25
+ connected: false,
26
+ accountEmail: null,
27
+ lastConnectedAt: null,
28
+ accessMode: 'read_write',
29
+ };
30
+ }
31
+
32
+ return {
33
+ id: row.id || null,
34
+ status: row.status || 'not_connected',
35
+ connected: row.status === 'connected',
36
+ accountEmail: row.account_email || null,
37
+ lastConnectedAt: row.last_connected_at || null,
38
+ accessMode: getConnectionAccessMode(row),
39
+ };
40
+ }
41
+
42
+ function summarizeAppConnection(app, connectionRows, envStatus) {
43
+ const accounts = (Array.isArray(connectionRows) ? connectionRows : [])
44
+ .slice()
45
+ .sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')))
46
+ .map((row) => summarizeAccountRow(row, envStatus));
47
+ const connectedAccounts = accounts.filter((account) => account.connected);
48
+ return {
49
+ id: app.id,
50
+ label: app.label,
51
+ description: app.description,
52
+ accounts,
53
+ connection: {
54
+ status: !envStatus.configured ? 'env_not_configured' : connectedAccounts.length > 0 ? 'connected' : 'not_connected',
55
+ connected: connectedAccounts.length > 0,
56
+ accountCount: connectedAccounts.length,
57
+ accountEmail: connectedAccounts.length === 1 ? connectedAccounts[0].accountEmail : null,
58
+ lastConnectedAt: connectedAccounts.map((account) => account.lastConnectedAt).filter(Boolean).sort().reverse()[0] || null,
59
+ },
60
+ availableToolCount: envStatus.configured && connectedAccounts.length > 0 ? HOME_ASSISTANT_TOOL_DEFINITIONS.length : 0,
61
+ };
62
+ }
63
+
64
+ function buildHomeAssistantSnapshot(provider, connectionRows, context = {}) {
65
+ const env = provider.getEnvStatus(context);
66
+ const byApp = new Map();
67
+ for (const row of Array.isArray(connectionRows) ? connectionRows : []) {
68
+ const appId = String(row.app_key || '').trim();
69
+ if (!byApp.has(appId)) byApp.set(appId, []);
70
+ byApp.get(appId).push(row);
71
+ }
72
+ const appSnapshots = [HOME_ASSISTANT_APP].map((app) => summarizeAppConnection(app, byApp.get(app.id) || [], env));
73
+ const connectedAccounts = appSnapshots.flatMap((app) => app.accounts.filter((account) => account.connected));
74
+ return {
75
+ id: provider.key,
76
+ label: provider.label,
77
+ description: provider.description,
78
+ icon: provider.icon,
79
+ apps: appSnapshots,
80
+ env,
81
+ connection: {
82
+ status: !env.configured ? 'env_not_configured' : connectedAccounts.length > 0 ? 'connected' : 'not_connected',
83
+ connected: connectedAccounts.length > 0,
84
+ accountEmail: connectedAccounts.length === 1 ? connectedAccounts[0].accountEmail : null,
85
+ accountCount: connectedAccounts.length,
86
+ appCount: appSnapshots.filter((app) => app.connection.connected).length,
87
+ lastConnectedAt: connectedAccounts.map((account) => account.lastConnectedAt).filter(Boolean).sort().reverse()[0] || null,
88
+ },
89
+ availableToolCount: appSnapshots.reduce((total, app) => total + app.availableToolCount, 0),
90
+ connectPrompt: provider.connectPrompt,
91
+ supportsMultipleAccounts: provider.supportsMultipleAccounts,
92
+ connectionMethod: provider.connectionMethod,
93
+ };
94
+ }
95
+
96
+ module.exports = {
97
+ buildHomeAssistantSnapshot,
98
+ };
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const { decryptValue } = require('../secrets');
4
+ const { homeAssistantRequest, normalizeHomeAssistantBaseUrl, requireText, trimText } = require('./network');
5
+
6
+ async function fetchHomeAssistantConfig(credentials) {
7
+ return homeAssistantRequest(credentials, { method: 'GET', path: '/api/config' });
8
+ }
9
+
10
+ function parseCredentials(connection) {
11
+ try {
12
+ return JSON.parse(decryptValue(connection?.credentials_json || '{}') || '{}');
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ function connectionCredentials(connection) {
19
+ const credentials = parseCredentials(connection);
20
+ return {
21
+ baseUrl: normalizeHomeAssistantBaseUrl(credentials.baseUrl),
22
+ token: requireText(credentials.token, 'Home Assistant token'),
23
+ };
24
+ }
25
+
26
+ function filterStates(states, args = {}) {
27
+ const domain = trimText(args.entity_domain).toLowerCase();
28
+ const limit = Math.max(1, Math.min(Number(args.limit) || 100, 500));
29
+ const filtered = Array.isArray(states)
30
+ ? states.filter((state) => !domain || String(state?.entity_id || '').toLowerCase().startsWith(`${domain}.`))
31
+ : [];
32
+ return filtered.slice(0, limit).map((state) => ({
33
+ entity_id: state.entity_id || null,
34
+ state: state.state || null,
35
+ attributes: state.attributes || {},
36
+ last_changed: state.last_changed || null,
37
+ last_updated: state.last_updated || null,
38
+ }));
39
+ }
40
+
41
+ function validateServiceSegment(value, label) {
42
+ const text = requireText(value, label).toLowerCase();
43
+ if (!/^[a-z0-9_]+$/.test(text)) {
44
+ throw new Error(`${label} must contain only lowercase letters, numbers, and underscores.`);
45
+ }
46
+ return text;
47
+ }
48
+
49
+ async function executeHomeAssistantTool(toolName, args, connection) {
50
+ const credentials = connectionCredentials(connection);
51
+
52
+ switch (toolName) {
53
+ case 'home_assistant_get_config':
54
+ return { result: await fetchHomeAssistantConfig(credentials) };
55
+ case 'home_assistant_list_states':
56
+ return {
57
+ result: filterStates(
58
+ await homeAssistantRequest(credentials, { method: 'GET', path: '/api/states' }),
59
+ args,
60
+ ),
61
+ };
62
+ case 'home_assistant_get_state':
63
+ return {
64
+ result: await homeAssistantRequest(credentials, {
65
+ method: 'GET',
66
+ path: `/api/states/${encodeURIComponent(requireText(args.entity_id, 'entity_id'))}`,
67
+ }),
68
+ };
69
+ case 'home_assistant_call_service': {
70
+ const domain = validateServiceSegment(args.domain, 'domain');
71
+ const service = validateServiceSegment(args.service, 'service');
72
+ const serviceData = args.service_data && typeof args.service_data === 'object' && !Array.isArray(args.service_data)
73
+ ? args.service_data
74
+ : {};
75
+ return {
76
+ result: await homeAssistantRequest(credentials, {
77
+ method: 'POST',
78
+ path: `/api/services/${domain}/${service}`,
79
+ body: serviceData,
80
+ }),
81
+ };
82
+ }
83
+ case 'home_assistant_api_request':
84
+ return {
85
+ result: await homeAssistantRequest(credentials, {
86
+ method: args.method,
87
+ path: requireText(args.path, 'path'),
88
+ query: args.query && typeof args.query === 'object' ? args.query : {},
89
+ body: args.body && typeof args.body === 'object' ? args.body : undefined,
90
+ }),
91
+ };
92
+ default:
93
+ return null;
94
+ }
95
+ }
96
+
97
+ module.exports = {
98
+ executeHomeAssistantTool,
99
+ fetchHomeAssistantConfig,
100
+ parseCredentials,
101
+ };
@@ -3,6 +3,7 @@
3
3
  const { createFigmaProvider } = require('./figma/provider');
4
4
  const { createGoogleWorkspaceProvider } = require('./google/provider');
5
5
  const { createGithubProvider } = require('./github/provider');
6
+ const { createHomeAssistantProvider } = require('./home_assistant/provider');
6
7
  const { createTrelloProvider } = require('./trello/provider');
7
8
  const { createMicrosoftProvider } = require('./microsoft/provider');
8
9
  const { createNotionProvider } = require('./notion/provider');
@@ -22,6 +23,7 @@ function createIntegrationRegistry(options = {}) {
22
23
  createTrelloProvider(),
23
24
  createWeatherProvider(),
24
25
  createSpotifyProvider(),
26
+ createHomeAssistantProvider(),
25
27
  createWhatsAppPersonalProvider(options),
26
28
  ];
27
29
  const byKey = new Map(providers.map((provider) => [provider.key, provider]));
@@ -80,9 +80,125 @@ class WorkspaceManager {
80
80
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
81
81
  throw new Error(`${label} is outside the per-user workspace.`);
82
82
  }
83
+ this._assertResolvedPathInsideRoot(root, absolute, label);
83
84
  return absolute;
84
85
  }
85
86
 
87
+ _assertResolvedPathInsideRoot(root, absolute, label = 'path') {
88
+ const realRoot = fs.realpathSync.native(root);
89
+ let targetToCheck = absolute;
90
+ while (!fs.existsSync(targetToCheck)) {
91
+ const parent = path.dirname(targetToCheck);
92
+ if (parent === targetToCheck) break;
93
+ targetToCheck = path.dirname(targetToCheck);
94
+ }
95
+ const realTarget = fs.realpathSync.native(targetToCheck);
96
+ const relative = path.relative(realRoot, realTarget);
97
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
98
+ throw new Error(`${label} is outside the per-user workspace.`);
99
+ }
100
+ }
101
+
102
+ _toWorkspaceRelative(root, absolute) {
103
+ const relative = path.relative(root, absolute);
104
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
105
+ ? relative.split(path.sep).join('/')
106
+ : '';
107
+ }
108
+
109
+ listExplorerDirectory(userId, options = {}) {
110
+ const root = this._ensureWorkspaceRootSync(userId);
111
+ const dirPath = this.resolvePath(userId, options.path || '.', 'path');
112
+ let stats;
113
+ try {
114
+ stats = fs.statSync(dirPath);
115
+ } catch (err) {
116
+ return { path: this._toWorkspaceRelative(root, dirPath), entries: [], error: err.message };
117
+ }
118
+ if (!stats.isDirectory()) {
119
+ return { path: this._toWorkspaceRelative(root, dirPath), entries: [], error: 'Path is not a directory.' };
120
+ }
121
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true }).flatMap((entry) => {
122
+ const fullPath = path.join(dirPath, entry.name);
123
+ let entryStats;
124
+ try {
125
+ entryStats = fs.lstatSync(fullPath);
126
+ if (entryStats.isSymbolicLink()) return [];
127
+ entryStats = fs.statSync(fullPath);
128
+ } catch {
129
+ return [];
130
+ }
131
+ if (!entryStats.isDirectory() && !entryStats.isFile()) return [];
132
+ return [{
133
+ name: entry.name,
134
+ type: entryStats.isDirectory() ? 'directory' : 'file',
135
+ path: this._toWorkspaceRelative(root, fullPath),
136
+ size: entryStats.size,
137
+ mtime: entryStats.mtime.toISOString(),
138
+ }];
139
+ }).sort((a, b) => {
140
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
141
+ return a.name.localeCompare(b.name);
142
+ });
143
+ return {
144
+ path: this._toWorkspaceRelative(root, dirPath),
145
+ parentPath: this._toWorkspaceRelative(root, path.dirname(dirPath)),
146
+ entries,
147
+ };
148
+ }
149
+
150
+ readExplorerFile(userId, options = {}) {
151
+ const root = this._ensureWorkspaceRootSync(userId);
152
+ const filePath = this.resolvePath(userId, options.path || '', 'path');
153
+ const stats = fs.statSync(filePath);
154
+ if (!stats.isFile()) {
155
+ throw new Error('Path is not a file.');
156
+ }
157
+ const maxBytes = Number(options.maxBytes || 1024 * 1024);
158
+ if (stats.size > maxBytes) {
159
+ const error = new Error(`File is too large to edit in the browser (${stats.size} bytes).`);
160
+ error.code = 'WORKSPACE_FILE_TOO_LARGE';
161
+ throw error;
162
+ }
163
+ return {
164
+ path: this._toWorkspaceRelative(root, filePath),
165
+ name: path.basename(filePath),
166
+ content: fs.readFileSync(filePath, 'utf8'),
167
+ size: stats.size,
168
+ mtime: stats.mtime.toISOString(),
169
+ };
170
+ }
171
+
172
+ writeExplorerFile(userId, options = {}) {
173
+ const root = this._ensureWorkspaceRootSync(userId);
174
+ const filePath = this.resolvePath(userId, options.path || '', 'path');
175
+ const content = String(options.content ?? '');
176
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
177
+ fs.writeFileSync(filePath, content, 'utf8');
178
+ const stats = fs.statSync(filePath);
179
+ return {
180
+ success: true,
181
+ path: this._toWorkspaceRelative(root, filePath),
182
+ size: stats.size,
183
+ mtime: stats.mtime.toISOString(),
184
+ };
185
+ }
186
+
187
+ getExplorerDownload(userId, options = {}) {
188
+ const root = this._ensureWorkspaceRootSync(userId);
189
+ const filePath = this.resolvePath(userId, options.path || '', 'path');
190
+ const stats = fs.statSync(filePath);
191
+ if (!stats.isFile()) {
192
+ throw new Error('Path is not a file.');
193
+ }
194
+ return {
195
+ root,
196
+ absolutePath: filePath,
197
+ relativePath: this._toWorkspaceRelative(root, filePath),
198
+ filename: path.basename(filePath),
199
+ };
200
+ }
201
+
86
202
  readFile(userId, options = {}) {
87
203
  let filePath;
88
204
  try {