@agentworkspaceos/hermes 0.1.0
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.
- package/README.md +45 -0
- package/bin/agentos-hermes.js +1187 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @agentworkspaceos/hermes
|
|
2
|
+
|
|
3
|
+
AgentOS connector CLI for pairing a local Hermes runtime with an AgentOS workspace.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
Run this command from the machine where Hermes is installed:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx --yes @agentworkspaceos/hermes@latest connect \
|
|
11
|
+
--mode plugin \
|
|
12
|
+
--pair AGOS-XXXX-XXXX \
|
|
13
|
+
--agentos-url https://agentos-local.rewardsbunny.com \
|
|
14
|
+
--hermes-url http://127.0.0.1:8642 \
|
|
15
|
+
--dashboard-url http://127.0.0.1:9119
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The connector sends safe runtime metadata and heartbeat status to AgentOS. Secrets stay on the local machine.
|
|
19
|
+
|
|
20
|
+
## Options
|
|
21
|
+
|
|
22
|
+
```txt
|
|
23
|
+
agentos-hermes connect --pair <code> [options]
|
|
24
|
+
|
|
25
|
+
--agentos-url <url> AgentOS web app URL
|
|
26
|
+
--hermes-url <url> Local Hermes gateway URL
|
|
27
|
+
--dashboard-url <url> Optional Hermes dashboard URL
|
|
28
|
+
--mode <mode> plugin, sidecar, or direct-url
|
|
29
|
+
--config-dir <path> Local config directory
|
|
30
|
+
--hermes-command <cmd> Hermes CLI executable
|
|
31
|
+
--interval-ms <ms> Heartbeat interval
|
|
32
|
+
--skip-inventory Send basic heartbeat metadata only
|
|
33
|
+
--once Send one heartbeat and exit
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Publish
|
|
37
|
+
|
|
38
|
+
The package is configured for public scoped publishing:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm login
|
|
42
|
+
npm publish --access public
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
You must own or have publish access to the `@agentos` npm scope before publishing.
|
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const VERSION = "0.1.0";
|
|
8
|
+
const DEFAULT_AGENTOS_URL = "http://localhost:3000";
|
|
9
|
+
const DEFAULT_HERMES_URL = "http://127.0.0.1:8642";
|
|
10
|
+
const DEFAULT_MODE = "plugin";
|
|
11
|
+
const DEFAULT_HERMES_COMMAND = "hermes";
|
|
12
|
+
const HERMES_COMMAND_TIMEOUT_MS = 2500;
|
|
13
|
+
const PARENT_AGENT_NAME = "Hermes Main Orchestrator";
|
|
14
|
+
|
|
15
|
+
async function main(argv) {
|
|
16
|
+
const [command, ...args] = argv;
|
|
17
|
+
|
|
18
|
+
if (!command || command === "--help" || command === "-h") {
|
|
19
|
+
printHelp();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (command === "--version" || command === "-v") {
|
|
24
|
+
console.log(VERSION);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (command !== "connect") {
|
|
29
|
+
throw new Error(`Unknown command "${command}". Run agentos-hermes --help.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await connect(args);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function connect(args) {
|
|
36
|
+
const options = parseConnectArgs(args);
|
|
37
|
+
const agentosUrl = normalizeBaseUrl(options.agentosUrl ?? process.env.AGENTOS_URL ?? DEFAULT_AGENTOS_URL);
|
|
38
|
+
const hermesUrl = normalizeBaseUrl(options.hermesUrl ?? process.env.HERMES_BASE_URL ?? DEFAULT_HERMES_URL);
|
|
39
|
+
const dashboardUrl = options.dashboardUrl ? normalizeBaseUrl(options.dashboardUrl) : undefined;
|
|
40
|
+
const mode = options.mode ?? DEFAULT_MODE;
|
|
41
|
+
const pairingCode = options.pair;
|
|
42
|
+
const hermesCommand = options.hermesCommand ?? process.env.HERMES_CLI_COMMAND ?? DEFAULT_HERMES_COMMAND;
|
|
43
|
+
|
|
44
|
+
if (!pairingCode) {
|
|
45
|
+
throw new Error("Missing --pair <code>.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!["plugin", "sidecar", "direct-url"].includes(mode)) {
|
|
49
|
+
throw new Error('--mode must be "plugin", "sidecar", or "direct-url".');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hermesHealth = await detectHermes(hermesUrl);
|
|
53
|
+
const metadata = options.skipInventory
|
|
54
|
+
? createFallbackMetadata(hermesHealth)
|
|
55
|
+
: await collectHermesInventory({ hermesCommand, hermesHealth });
|
|
56
|
+
const configPath = await persistLocalConfig({
|
|
57
|
+
agentosUrl,
|
|
58
|
+
configDir: options.configDir,
|
|
59
|
+
dashboardUrl,
|
|
60
|
+
hermesUrl,
|
|
61
|
+
mode,
|
|
62
|
+
pairingCode,
|
|
63
|
+
});
|
|
64
|
+
const payload = {
|
|
65
|
+
connectorMode: mode,
|
|
66
|
+
connectorVersion: VERSION,
|
|
67
|
+
hermes: {
|
|
68
|
+
baseUrl: hermesUrl,
|
|
69
|
+
dashboardUrl,
|
|
70
|
+
detected: hermesHealth.detected,
|
|
71
|
+
platform: hermesHealth.platform,
|
|
72
|
+
status: hermesHealth.status,
|
|
73
|
+
},
|
|
74
|
+
machineLabel: hostname(),
|
|
75
|
+
metadata,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const sendHeartbeat = async () => postHeartbeat(agentosUrl, pairingCode, payload);
|
|
79
|
+
const session = await sendHeartbeat();
|
|
80
|
+
|
|
81
|
+
console.log("AgentOS Hermes connector registered");
|
|
82
|
+
console.log(`Workspace: ${session.workspaceSlug}`);
|
|
83
|
+
console.log(`Pairing code: ${session.pairingCode}`);
|
|
84
|
+
console.log(`Status: ${session.status}`);
|
|
85
|
+
console.log(`Mode: ${mode}`);
|
|
86
|
+
console.log(`Hermes detected: ${hermesHealth.detected ? "yes" : "no"}`);
|
|
87
|
+
console.log(
|
|
88
|
+
`Inventory: ${metadata.skills?.length ?? 0} skills, ${metadata.toolsets?.length ?? 0} enabled toolsets, ${
|
|
89
|
+
metadata.mcpServers?.length ?? 0
|
|
90
|
+
} MCP servers, ${metadata.identityFiles?.length ?? 0} identity files, ${metadata.childAgents?.length ?? 0} child agents`,
|
|
91
|
+
);
|
|
92
|
+
console.log(`Local config: ${configPath}`);
|
|
93
|
+
console.log("Secrets stay local. Full skill docs, identity files, and redacted config were sent.");
|
|
94
|
+
if (session.status === "pending_confirmation") {
|
|
95
|
+
console.log("Confirm this Hermes runtime in AgentOS before tasks are sent.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (options.once) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const intervalMs = options.intervalMs ?? 15000;
|
|
103
|
+
|
|
104
|
+
console.log(`Sending heartbeat every ${Math.round(intervalMs / 1000)}s. Press Ctrl+C to stop.`);
|
|
105
|
+
|
|
106
|
+
await runHeartbeatLoop(sendHeartbeat, intervalMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseConnectArgs(args) {
|
|
110
|
+
const options = {};
|
|
111
|
+
|
|
112
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
113
|
+
const arg = args[index];
|
|
114
|
+
const next = args[index + 1];
|
|
115
|
+
|
|
116
|
+
if (arg === "--pair") {
|
|
117
|
+
options.pair = requireValue(arg, next);
|
|
118
|
+
index += 1;
|
|
119
|
+
} else if (arg === "--agentos-url") {
|
|
120
|
+
options.agentosUrl = requireValue(arg, next);
|
|
121
|
+
index += 1;
|
|
122
|
+
} else if (arg === "--hermes-url") {
|
|
123
|
+
options.hermesUrl = requireValue(arg, next);
|
|
124
|
+
index += 1;
|
|
125
|
+
} else if (arg === "--dashboard-url") {
|
|
126
|
+
options.dashboardUrl = requireValue(arg, next);
|
|
127
|
+
index += 1;
|
|
128
|
+
} else if (arg === "--mode") {
|
|
129
|
+
options.mode = requireValue(arg, next);
|
|
130
|
+
index += 1;
|
|
131
|
+
} else if (arg === "--config-dir") {
|
|
132
|
+
options.configDir = requireValue(arg, next);
|
|
133
|
+
index += 1;
|
|
134
|
+
} else if (arg === "--hermes-command") {
|
|
135
|
+
options.hermesCommand = requireValue(arg, next);
|
|
136
|
+
index += 1;
|
|
137
|
+
} else if (arg === "--interval-ms") {
|
|
138
|
+
options.intervalMs = Number(requireValue(arg, next));
|
|
139
|
+
index += 1;
|
|
140
|
+
} else if (arg === "--once") {
|
|
141
|
+
options.once = true;
|
|
142
|
+
} else if (arg === "--skip-inventory") {
|
|
143
|
+
options.skipInventory = true;
|
|
144
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
145
|
+
printConnectHelp();
|
|
146
|
+
process.exit(0);
|
|
147
|
+
} else {
|
|
148
|
+
throw new Error(`Unknown option "${arg}".`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return options;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function requireValue(name, value) {
|
|
156
|
+
if (!value || value.startsWith("--")) {
|
|
157
|
+
throw new Error(`Missing value for ${name}.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function detectHermes(hermesUrl) {
|
|
164
|
+
try {
|
|
165
|
+
const response = await fetch(`${hermesUrl}/health`, {
|
|
166
|
+
headers: {
|
|
167
|
+
accept: "application/json",
|
|
168
|
+
},
|
|
169
|
+
method: "GET",
|
|
170
|
+
});
|
|
171
|
+
const text = await response.text();
|
|
172
|
+
const body = text ? JSON.parse(text) : {};
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
detected: response.ok,
|
|
176
|
+
platform: typeof body.platform === "string" ? body.platform : null,
|
|
177
|
+
status: typeof body.status === "string" ? body.status : response.ok ? "ok" : "unavailable",
|
|
178
|
+
};
|
|
179
|
+
} catch {
|
|
180
|
+
return {
|
|
181
|
+
detected: false,
|
|
182
|
+
platform: null,
|
|
183
|
+
status: "unavailable",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function collectHermesInventory({ hermesCommand, hermesHealth }) {
|
|
189
|
+
const [version, status, tools, skills, mcp, profiles, plugins, cron, insights] = await Promise.all([
|
|
190
|
+
runHermesCommand(hermesCommand, ["version"]),
|
|
191
|
+
runHermesCommand(hermesCommand, ["status"]),
|
|
192
|
+
runHermesCommand(hermesCommand, ["tools", "list"]),
|
|
193
|
+
runHermesCommand(hermesCommand, ["skills", "list"]),
|
|
194
|
+
runHermesCommand(hermesCommand, ["mcp", "list"]),
|
|
195
|
+
runHermesCommand(hermesCommand, ["profile", "list"]),
|
|
196
|
+
runHermesCommand(hermesCommand, ["plugins", "list"]),
|
|
197
|
+
runHermesCommand(hermesCommand, ["cron", "list"]),
|
|
198
|
+
runHermesCommand(hermesCommand, ["insights", "--days", "30"]),
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
const versionMetadata = parseVersionOutput(version.stdout);
|
|
202
|
+
const projectPath = parseProjectPath(version.stdout);
|
|
203
|
+
const statusMetadata = parseStatusOutput(status.stdout);
|
|
204
|
+
const toolsetsDetailed = parseToolsetsOutput(tools.stdout);
|
|
205
|
+
const skillCatalog = projectPath ? await readSkillCatalog(projectPath) : [];
|
|
206
|
+
const skillsDetailed = mergeSkillDetails(parseSkillsOutput(skills.stdout), skillCatalog);
|
|
207
|
+
const mcpServersDetailed = parseMcpOutput(mcp.stdout);
|
|
208
|
+
const profilesDetailed = parseProfilesOutput(profiles.stdout);
|
|
209
|
+
const pluginsDetailed = parsePluginsOutput(plugins.stdout);
|
|
210
|
+
const cronJobs = parseCronOutput(cron.stdout);
|
|
211
|
+
const usageInsights = parseInsightsOutput(insights.stdout);
|
|
212
|
+
const configFiles = await readHermesConfigFiles(hermesCommand);
|
|
213
|
+
const identityFiles = await readHermesIdentityFiles(hermesCommand, projectPath);
|
|
214
|
+
const activeProfile = profilesDetailed.find((profile) => profile.active) ?? profilesDetailed[0];
|
|
215
|
+
|
|
216
|
+
return compactObject({
|
|
217
|
+
...createFallbackMetadata(hermesHealth),
|
|
218
|
+
...versionMetadata,
|
|
219
|
+
...statusMetadata,
|
|
220
|
+
configFiles: configFiles.length ? configFiles : undefined,
|
|
221
|
+
cronJobs: cronJobs.length ? cronJobs : undefined,
|
|
222
|
+
identityFiles: identityFiles.length ? identityFiles : undefined,
|
|
223
|
+
insights: usageInsights,
|
|
224
|
+
mcpServers: mcpServersDetailed.map((server) => server.name),
|
|
225
|
+
mcpServersDetailed: mcpServersDetailed.length ? mcpServersDetailed : undefined,
|
|
226
|
+
plugins: pluginsDetailed.length ? pluginsDetailed : undefined,
|
|
227
|
+
profile: activeProfile?.name ?? statusMetadata.profile ?? "default",
|
|
228
|
+
profiles: profilesDetailed.length ? profilesDetailed : undefined,
|
|
229
|
+
skills: skillsDetailed.map((skill) => skill.name),
|
|
230
|
+
skillsDetailed: skillsDetailed.length ? skillsDetailed : undefined,
|
|
231
|
+
toolsets: toolsetsDetailed.filter((toolset) => toolset.status === "enabled").map((toolset) => toolset.name),
|
|
232
|
+
toolsetsDetailed: toolsetsDetailed.length ? toolsetsDetailed : undefined,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseInsightsOutput(output) {
|
|
237
|
+
if (!output || !/Hermes Insights|Overview|Sessions:/i.test(output)) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const clean = stripAnsi(output);
|
|
242
|
+
const period = clean.match(/^\s*Period:\s+(.+)$/m)?.[1]?.trim();
|
|
243
|
+
const insights = compactObject({
|
|
244
|
+
period: sanitizeDisplayString(period),
|
|
245
|
+
...parseOverviewMetrics(clean),
|
|
246
|
+
models: parseInsightsTable(clean, "Models Used", "Platforms", parseModelsRow),
|
|
247
|
+
platforms: parseInsightsTable(clean, "Platforms", "Top Tools", parsePlatformsRow),
|
|
248
|
+
topTools: parseInsightsTable(clean, "Top Tools", "Top Skills", parseToolRow),
|
|
249
|
+
topSkills: parseInsightsTable(clean, "Top Skills", "Activity Patterns", parseSkillRow),
|
|
250
|
+
weekdayActivity: parseWeekdayActivity(clean),
|
|
251
|
+
...parseActivitySummary(clean),
|
|
252
|
+
notableSessions: parseInsightsTable(clean, "Notable Sessions", undefined, parseNotableSessionRow),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return Object.keys(insights).length ? insights : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseOverviewMetrics(output) {
|
|
259
|
+
return compactObject({
|
|
260
|
+
activeTime: output.match(/Active time:\s+([^\n]+?)\s{2,}Avg session:/)?.[1]?.trim(),
|
|
261
|
+
avgMessagesPerSession: parseNumber(output.match(/Avg msgs\/session:\s+([\d,.]+)/)?.[1]),
|
|
262
|
+
avgSession: output.match(/Avg session:\s+([^\n]+)/)?.[1]?.trim(),
|
|
263
|
+
inputTokens: parseNumber(output.match(/Input tokens:\s+([\d,]+)/)?.[1]),
|
|
264
|
+
messages: parseNumber(output.match(/Messages:\s+([\d,]+)/)?.[1]),
|
|
265
|
+
outputTokens: parseNumber(output.match(/Output tokens:\s+([\d,]+)/)?.[1]),
|
|
266
|
+
sessions: parseNumber(output.match(/Sessions:\s+([\d,]+)/)?.[1]),
|
|
267
|
+
totalTokens: parseNumber(output.match(/Total tokens:\s+([\d,]+)/)?.[1]),
|
|
268
|
+
toolCalls: parseNumber(output.match(/Tool calls:\s+([\d,]+)/)?.[1]),
|
|
269
|
+
userMessages: parseNumber(output.match(/User messages:\s+([\d,]+)/)?.[1]),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseInsightsTable(output, startLabel, endLabel, rowParser) {
|
|
274
|
+
const section = sliceInsightsSection(output, startLabel, endLabel);
|
|
275
|
+
|
|
276
|
+
if (!section) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rows = section
|
|
281
|
+
.split("\n")
|
|
282
|
+
.map((line) => rowParser(line.trim()))
|
|
283
|
+
.filter(Boolean);
|
|
284
|
+
|
|
285
|
+
return rows.length ? rows : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function sliceInsightsSection(output, startLabel, endLabel) {
|
|
289
|
+
const startIndex = output.search(new RegExp(escapeRegExp(startLabel), "i"));
|
|
290
|
+
|
|
291
|
+
if (startIndex < 0) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const sliced = output.slice(startIndex + startLabel.length);
|
|
296
|
+
const endIndex = endLabel ? sliced.search(new RegExp(escapeRegExp(endLabel), "i")) : -1;
|
|
297
|
+
|
|
298
|
+
return (endIndex >= 0 ? sliced.slice(0, endIndex) : sliced)
|
|
299
|
+
.split("\n")
|
|
300
|
+
.filter((line) => !/^[\s─]+$/.test(line))
|
|
301
|
+
.join("\n");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseModelsRow(line) {
|
|
305
|
+
const match = line.match(/^(.+?)\s+([\d,]+)\s+([\d,]+)$/);
|
|
306
|
+
|
|
307
|
+
return match
|
|
308
|
+
? compactObject({
|
|
309
|
+
label: sanitizeDisplayString(match[1]),
|
|
310
|
+
primary: `${parseNumber(match[2]) ?? 0} sessions`,
|
|
311
|
+
secondary: `${formatCompactNumber(parseNumber(match[3]) ?? 0)} tokens`,
|
|
312
|
+
value: parseNumber(match[3]),
|
|
313
|
+
})
|
|
314
|
+
: undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parsePlatformsRow(line) {
|
|
318
|
+
const match = line.match(/^(.+?)\s+([\d,]+)\s+([\d,]+)\s+([\d,]+)$/);
|
|
319
|
+
|
|
320
|
+
return match
|
|
321
|
+
? compactObject({
|
|
322
|
+
label: sanitizeDisplayString(match[1]),
|
|
323
|
+
primary: `${parseNumber(match[2]) ?? 0} sessions`,
|
|
324
|
+
secondary: `${formatCompactNumber(parseNumber(match[4]) ?? 0)} tokens`,
|
|
325
|
+
value: parseNumber(match[2]),
|
|
326
|
+
})
|
|
327
|
+
: undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseToolRow(line) {
|
|
331
|
+
const match = line.match(/^(.+?)\s+([\d,]+)\s+([\d.]+%)$/);
|
|
332
|
+
|
|
333
|
+
return match
|
|
334
|
+
? compactObject({
|
|
335
|
+
label: sanitizeDisplayString(match[1]),
|
|
336
|
+
primary: `${parseNumber(match[2]) ?? 0} calls`,
|
|
337
|
+
secondary: match[3],
|
|
338
|
+
value: parseNumber(match[2]),
|
|
339
|
+
})
|
|
340
|
+
: undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseSkillRow(line) {
|
|
344
|
+
const match = line.match(/^(.+?)\s+([\d,]+)\s+([\d,]+)\s+(.+)$/);
|
|
345
|
+
|
|
346
|
+
return match
|
|
347
|
+
? compactObject({
|
|
348
|
+
label: sanitizeDisplayString(match[1]),
|
|
349
|
+
primary: `${parseNumber(match[2]) ?? 0} loads`,
|
|
350
|
+
secondary: `${parseNumber(match[3]) ?? 0} edits, last used ${sanitizeDisplayString(match[4])}`,
|
|
351
|
+
value: parseNumber(match[2]),
|
|
352
|
+
})
|
|
353
|
+
: undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function parseNotableSessionRow(line) {
|
|
357
|
+
const match = line.match(/^(.+?)\s{2,}(.+?)\s{2,}\((.+)\)$/);
|
|
358
|
+
|
|
359
|
+
return match
|
|
360
|
+
? compactObject({
|
|
361
|
+
label: sanitizeDisplayString(match[1]),
|
|
362
|
+
primary: sanitizeDisplayString(match[2]),
|
|
363
|
+
secondary: sanitizeDisplayString(match[3]),
|
|
364
|
+
})
|
|
365
|
+
: undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parseWeekdayActivity(output) {
|
|
369
|
+
const rows = [...output.matchAll(/^\s*(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+[█\s]+\s+([\d,]+)$/gm)].map((match) =>
|
|
370
|
+
compactObject({
|
|
371
|
+
label: match[1],
|
|
372
|
+
value: parseNumber(match[2]),
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
return rows.length ? rows : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseActivitySummary(output) {
|
|
380
|
+
return compactObject({
|
|
381
|
+
activeDays: parseNumber(output.match(/Active days:\s+([\d,]+)/)?.[1]),
|
|
382
|
+
bestStreak: output.match(/Best streak:\s+(.+)$/m)?.[1]?.trim(),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function createFallbackMetadata(hermesHealth) {
|
|
387
|
+
return {
|
|
388
|
+
agentName: PARENT_AGENT_NAME,
|
|
389
|
+
mcpServers: [],
|
|
390
|
+
profile: "default",
|
|
391
|
+
runtimeType: "hermes",
|
|
392
|
+
skills: [],
|
|
393
|
+
status: hermesHealth.detected ? "online" : "offline",
|
|
394
|
+
toolsets: [],
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function runHermesCommand(hermesCommand, args) {
|
|
399
|
+
return runCommand(hermesCommand, args);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function runCommand(command, args, options = {}) {
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
const child = spawn(command, args, {
|
|
405
|
+
cwd: options.cwd,
|
|
406
|
+
env: {
|
|
407
|
+
...process.env,
|
|
408
|
+
COLUMNS: "220",
|
|
409
|
+
NO_COLOR: "1",
|
|
410
|
+
TERM: "dumb",
|
|
411
|
+
},
|
|
412
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
413
|
+
});
|
|
414
|
+
let settled = false;
|
|
415
|
+
let stdout = "";
|
|
416
|
+
let stderr = "";
|
|
417
|
+
|
|
418
|
+
const timeout = setTimeout(() => {
|
|
419
|
+
if (settled) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
settled = true;
|
|
424
|
+
child.kill("SIGTERM");
|
|
425
|
+
resolve({ ok: false, stdout, stderr: `${stderr}\nTimed out running hermes ${args.join(" ")}`.trim() });
|
|
426
|
+
}, options.timeoutMs ?? HERMES_COMMAND_TIMEOUT_MS);
|
|
427
|
+
|
|
428
|
+
child.stdout.setEncoding("utf8");
|
|
429
|
+
child.stderr.setEncoding("utf8");
|
|
430
|
+
child.stdout.on("data", (chunk) => {
|
|
431
|
+
stdout += chunk;
|
|
432
|
+
});
|
|
433
|
+
child.stderr.on("data", (chunk) => {
|
|
434
|
+
stderr += chunk;
|
|
435
|
+
});
|
|
436
|
+
child.on("error", (error) => {
|
|
437
|
+
if (settled) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
settled = true;
|
|
442
|
+
clearTimeout(timeout);
|
|
443
|
+
resolve({ ok: false, stdout: "", stderr: error.message });
|
|
444
|
+
});
|
|
445
|
+
child.on("close", (status) => {
|
|
446
|
+
if (settled) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
settled = true;
|
|
451
|
+
clearTimeout(timeout);
|
|
452
|
+
resolve({ ok: status === 0, stdout: stripAnsi(stdout), stderr: stripAnsi(stderr) });
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function parseVersionOutput(output) {
|
|
458
|
+
const metadata = {};
|
|
459
|
+
const version = output.match(/Hermes Agent v([^\s]+)/);
|
|
460
|
+
const pythonVersion = output.match(/^Python:\s+(.+)$/m);
|
|
461
|
+
const openAiSdkVersion = output.match(/^OpenAI SDK:\s+(.+)$/m);
|
|
462
|
+
|
|
463
|
+
if (version) {
|
|
464
|
+
metadata.version = version[1];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (pythonVersion) {
|
|
468
|
+
metadata.pythonVersion = pythonVersion[1].trim();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (openAiSdkVersion) {
|
|
472
|
+
metadata.openAiSdkVersion = openAiSdkVersion[1].trim();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return metadata;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function parseProjectPath(output) {
|
|
479
|
+
const project = output.match(/^Project:\s+(.+)$/m);
|
|
480
|
+
|
|
481
|
+
return project ? project[1].trim() : undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function readSkillCatalog(projectPath) {
|
|
485
|
+
const python = join(projectPath, "venv", "bin", "python");
|
|
486
|
+
const script = `
|
|
487
|
+
import json
|
|
488
|
+
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
|
|
489
|
+
from tools.skills_tool import (
|
|
490
|
+
SKILLS_DIR,
|
|
491
|
+
_EXCLUDED_SKILL_DIRS,
|
|
492
|
+
_get_category_from_path,
|
|
493
|
+
_parse_frontmatter,
|
|
494
|
+
skill_matches_platform,
|
|
495
|
+
)
|
|
496
|
+
skills = []
|
|
497
|
+
seen = set()
|
|
498
|
+
dirs = []
|
|
499
|
+
if SKILLS_DIR.exists():
|
|
500
|
+
dirs.append(SKILLS_DIR)
|
|
501
|
+
dirs.extend(get_external_skills_dirs())
|
|
502
|
+
for scan_dir in dirs:
|
|
503
|
+
for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"):
|
|
504
|
+
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
|
|
505
|
+
continue
|
|
506
|
+
try:
|
|
507
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
508
|
+
frontmatter, body = _parse_frontmatter(content)
|
|
509
|
+
if not skill_matches_platform(frontmatter):
|
|
510
|
+
continue
|
|
511
|
+
name = str(frontmatter.get("name") or skill_md.parent.name)[:64]
|
|
512
|
+
if not name or name in seen:
|
|
513
|
+
continue
|
|
514
|
+
description = frontmatter.get("description", "")
|
|
515
|
+
if not description:
|
|
516
|
+
for line in body.strip().split("\\n"):
|
|
517
|
+
line = line.strip()
|
|
518
|
+
if line and not line.startswith("#"):
|
|
519
|
+
description = line
|
|
520
|
+
break
|
|
521
|
+
skills.append({
|
|
522
|
+
"name": name,
|
|
523
|
+
"category": _get_category_from_path(skill_md),
|
|
524
|
+
"description": description,
|
|
525
|
+
"path": str(skill_md),
|
|
526
|
+
"content": content,
|
|
527
|
+
})
|
|
528
|
+
seen.add(name)
|
|
529
|
+
except Exception:
|
|
530
|
+
continue
|
|
531
|
+
print(json.dumps(skills))
|
|
532
|
+
`.trim();
|
|
533
|
+
const result = await runCommand(python, ["-c", script], { cwd: projectPath, timeoutMs: 8000 });
|
|
534
|
+
|
|
535
|
+
if (!result.ok || !result.stdout) {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
const parsed = JSON.parse(result.stdout);
|
|
541
|
+
|
|
542
|
+
if (!Array.isArray(parsed)) {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return parsed
|
|
547
|
+
.map((skill) =>
|
|
548
|
+
compactObject({
|
|
549
|
+
category: sanitizeDisplayString(skill.category),
|
|
550
|
+
content: redactSensitiveContent(String(skill.content ?? "")),
|
|
551
|
+
contentLength: typeof skill.content === "string" ? skill.content.length : undefined,
|
|
552
|
+
contentRedacted: true,
|
|
553
|
+
description: sanitizeDisplayString(skill.description, 420),
|
|
554
|
+
name: sanitizeDisplayString(skill.name),
|
|
555
|
+
path: redactLocalPath(sanitizeDisplayString(skill.path, 420)),
|
|
556
|
+
}),
|
|
557
|
+
)
|
|
558
|
+
.filter((skill) => skill.name);
|
|
559
|
+
} catch {
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function mergeSkillDetails(listRows, catalogRows) {
|
|
565
|
+
const catalogByName = new Map(catalogRows.map((skill) => [skill.name, skill]));
|
|
566
|
+
const merged = listRows.map((skill) => compactObject({ ...catalogByName.get(skill.name), ...skill }));
|
|
567
|
+
const seen = new Set(merged.map((skill) => skill.name));
|
|
568
|
+
|
|
569
|
+
for (const skill of catalogRows) {
|
|
570
|
+
if (!seen.has(skill.name)) {
|
|
571
|
+
merged.push(skill);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return merged;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function readHermesConfigFiles(hermesCommand) {
|
|
579
|
+
const configPathResult = await runHermesCommand(hermesCommand, ["config", "path"]);
|
|
580
|
+
const configPath = configPathResult.stdout.trim();
|
|
581
|
+
|
|
582
|
+
if (!configPath) {
|
|
583
|
+
return [];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const files = [];
|
|
587
|
+
const hermesHome = dirname(configPath);
|
|
588
|
+
|
|
589
|
+
await addConfigFile(files, "Default config", configPath);
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
const profilesDir = join(hermesHome, "profiles");
|
|
593
|
+
const profiles = await readdir(profilesDir, { withFileTypes: true });
|
|
594
|
+
|
|
595
|
+
for (const profile of profiles) {
|
|
596
|
+
if (!profile.isDirectory()) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
await addConfigFile(files, `Profile config: ${profile.name}`, join(profilesDir, profile.name, "config.yaml"));
|
|
601
|
+
}
|
|
602
|
+
} catch {
|
|
603
|
+
// Profiles are optional.
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return files;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function readHermesIdentityFiles(hermesCommand, projectPath) {
|
|
610
|
+
const files = [];
|
|
611
|
+
const seen = new Set();
|
|
612
|
+
const configPathResult = await runHermesCommand(hermesCommand, ["config", "path"]);
|
|
613
|
+
const configPath = configPathResult.stdout.trim();
|
|
614
|
+
|
|
615
|
+
if (configPath) {
|
|
616
|
+
const hermesHome = dirname(configPath);
|
|
617
|
+
const profilesDir = join(hermesHome, "profiles");
|
|
618
|
+
|
|
619
|
+
await addIdentityFile(files, seen, {
|
|
620
|
+
label: "Default SOUL.md",
|
|
621
|
+
path: join(hermesHome, "SOUL.md"),
|
|
622
|
+
profile: "default",
|
|
623
|
+
type: "soul",
|
|
624
|
+
});
|
|
625
|
+
await addIdentityFile(files, seen, {
|
|
626
|
+
label: "Default AGENTS.md",
|
|
627
|
+
path: join(hermesHome, "AGENTS.md"),
|
|
628
|
+
profile: "default",
|
|
629
|
+
type: "agent-instructions",
|
|
630
|
+
});
|
|
631
|
+
await addIdentityFile(files, seen, {
|
|
632
|
+
label: "Default MEMORY.md",
|
|
633
|
+
path: join(hermesHome, "MEMORY.md"),
|
|
634
|
+
profile: "default",
|
|
635
|
+
type: "memory",
|
|
636
|
+
});
|
|
637
|
+
await addIdentityFile(files, seen, {
|
|
638
|
+
label: "Default .cursorrules",
|
|
639
|
+
path: join(hermesHome, ".cursorrules"),
|
|
640
|
+
profile: "default",
|
|
641
|
+
type: "rules",
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
const profiles = await readdir(profilesDir, { withFileTypes: true });
|
|
646
|
+
|
|
647
|
+
for (const profile of profiles) {
|
|
648
|
+
if (!profile.isDirectory()) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
await addProfileIdentityFiles(files, seen, profilesDir, profile.name);
|
|
653
|
+
}
|
|
654
|
+
} catch {
|
|
655
|
+
// Profiles are optional.
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (projectPath) {
|
|
660
|
+
await addIdentityFile(files, seen, {
|
|
661
|
+
label: "Hermes project AGENTS.md",
|
|
662
|
+
path: join(projectPath, "AGENTS.md"),
|
|
663
|
+
type: "project-instructions",
|
|
664
|
+
});
|
|
665
|
+
await addIdentityFile(files, seen, {
|
|
666
|
+
label: "Hermes project SOUL.md",
|
|
667
|
+
path: join(projectPath, "SOUL.md"),
|
|
668
|
+
type: "project-soul",
|
|
669
|
+
});
|
|
670
|
+
await addIdentityFile(files, seen, {
|
|
671
|
+
label: "Docker SOUL.md",
|
|
672
|
+
path: join(projectPath, "docker", "SOUL.md"),
|
|
673
|
+
type: "docker-soul",
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return files;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function addProfileIdentityFiles(files, seen, profilesDir, profileName) {
|
|
681
|
+
const profileDir = join(profilesDir, profileName);
|
|
682
|
+
|
|
683
|
+
await addIdentityFile(files, seen, {
|
|
684
|
+
label: `Profile SOUL.md: ${profileName}`,
|
|
685
|
+
path: join(profileDir, "SOUL.md"),
|
|
686
|
+
profile: profileName,
|
|
687
|
+
type: "profile-soul",
|
|
688
|
+
});
|
|
689
|
+
await addIdentityFile(files, seen, {
|
|
690
|
+
label: `Profile AGENTS.md: ${profileName}`,
|
|
691
|
+
path: join(profileDir, "AGENTS.md"),
|
|
692
|
+
profile: profileName,
|
|
693
|
+
type: "agent-instructions",
|
|
694
|
+
});
|
|
695
|
+
await addIdentityFile(files, seen, {
|
|
696
|
+
label: `Profile MEMORY.md: ${profileName}`,
|
|
697
|
+
path: join(profileDir, "MEMORY.md"),
|
|
698
|
+
profile: profileName,
|
|
699
|
+
type: "memory",
|
|
700
|
+
});
|
|
701
|
+
await addIdentityFile(files, seen, {
|
|
702
|
+
label: `Profile .cursorrules: ${profileName}`,
|
|
703
|
+
path: join(profileDir, ".cursorrules"),
|
|
704
|
+
profile: profileName,
|
|
705
|
+
type: "rules",
|
|
706
|
+
});
|
|
707
|
+
await addIdentityFile(files, seen, {
|
|
708
|
+
label: `Profile memory SOUL.md: ${profileName}`,
|
|
709
|
+
path: join(profileDir, "memory", "SOUL.md"),
|
|
710
|
+
profile: profileName,
|
|
711
|
+
type: "memory-soul",
|
|
712
|
+
});
|
|
713
|
+
await addIdentityFile(files, seen, {
|
|
714
|
+
label: `Profile memory AGENTS.md: ${profileName}`,
|
|
715
|
+
path: join(profileDir, "memory", "AGENTS.md"),
|
|
716
|
+
profile: profileName,
|
|
717
|
+
type: "memory-instructions",
|
|
718
|
+
});
|
|
719
|
+
await addIdentityFile(files, seen, {
|
|
720
|
+
label: `Profile memory MEMORY.md: ${profileName}`,
|
|
721
|
+
path: join(profileDir, "memory", "MEMORY.md"),
|
|
722
|
+
profile: profileName,
|
|
723
|
+
type: "memory",
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function addConfigFile(files, label, path) {
|
|
728
|
+
try {
|
|
729
|
+
const content = await readFile(path, "utf8");
|
|
730
|
+
|
|
731
|
+
files.push({
|
|
732
|
+
content: redactSensitiveContent(content),
|
|
733
|
+
label,
|
|
734
|
+
path: redactLocalPath(path),
|
|
735
|
+
redacted: true,
|
|
736
|
+
});
|
|
737
|
+
} catch {
|
|
738
|
+
// Missing or unreadable config files are not fatal to pairing.
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function addIdentityFile(files, seen, { label, path, profile, type }) {
|
|
743
|
+
if (seen.has(path)) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
seen.add(path);
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
const content = await readFile(path, "utf8");
|
|
751
|
+
|
|
752
|
+
files.push(
|
|
753
|
+
compactObject({
|
|
754
|
+
content: redactSensitiveContent(content),
|
|
755
|
+
label,
|
|
756
|
+
path: redactLocalPath(path),
|
|
757
|
+
profile,
|
|
758
|
+
redacted: true,
|
|
759
|
+
type,
|
|
760
|
+
}),
|
|
761
|
+
);
|
|
762
|
+
} catch {
|
|
763
|
+
// Missing identity files are expected across Hermes installs.
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function parseStatusOutput(output) {
|
|
768
|
+
const metadata = {};
|
|
769
|
+
const model = output.match(/^\s*Model:\s+(.+)$/m);
|
|
770
|
+
const provider = output.match(/^\s*Provider:\s+(.+)$/m);
|
|
771
|
+
const terminalBackend = output.match(/^\s*Backend:\s+(.+)$/m);
|
|
772
|
+
const sudo = output.match(/^\s*Sudo:\s+(.+)$/m);
|
|
773
|
+
const gatewayStatus = output.match(/\u25c6 Gateway Service[\s\S]*?^\s*Status:\s+(?:\u2713\s*)?(.+)$/m);
|
|
774
|
+
const scheduledJobs = output.match(/^\s*Jobs:\s+(\d+)\s+active,\s+(\d+)\s+total$/m);
|
|
775
|
+
const activeSessions = output.match(/^\s*Active:\s+(\d+)\s+session\(s\)$/m);
|
|
776
|
+
|
|
777
|
+
if (model) {
|
|
778
|
+
metadata.model = model[1].trim();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (provider) {
|
|
782
|
+
metadata.provider = provider[1].trim();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (terminalBackend) {
|
|
786
|
+
metadata.terminalBackend = terminalBackend[1].trim();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (sudo) {
|
|
790
|
+
metadata.sudoEnabled = /enabled/i.test(sudo[1]);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (gatewayStatus) {
|
|
794
|
+
metadata.gatewayStatus = gatewayStatus[1].trim();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (scheduledJobs) {
|
|
798
|
+
metadata.scheduledJobs = Number(scheduledJobs[2]);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (activeSessions) {
|
|
802
|
+
metadata.activeSessions = Number(activeSessions[1]);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return metadata;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function parseToolsetsOutput(output) {
|
|
809
|
+
return output
|
|
810
|
+
.split("\n")
|
|
811
|
+
.map((line) => line.match(/^\s*[\u2713\u2717]\s+(enabled|disabled)\s+(\S+)\s+(.+)$/))
|
|
812
|
+
.filter(Boolean)
|
|
813
|
+
.map((match) => ({
|
|
814
|
+
label: sanitizeDisplayString(match[3]),
|
|
815
|
+
name: sanitizeDisplayString(match[2]),
|
|
816
|
+
status: match[1],
|
|
817
|
+
}))
|
|
818
|
+
.filter((toolset) => toolset.name);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function parseSkillsOutput(output) {
|
|
822
|
+
return parseBoxRows(output)
|
|
823
|
+
.map((columns) => {
|
|
824
|
+
const [name, category, source, trust, status] = columns;
|
|
825
|
+
|
|
826
|
+
return compactObject({
|
|
827
|
+
category: sanitizeDisplayString(category),
|
|
828
|
+
name: sanitizeDisplayString(name),
|
|
829
|
+
source: sanitizeDisplayString(source),
|
|
830
|
+
status: sanitizeDisplayString(status),
|
|
831
|
+
trust: sanitizeDisplayString(trust),
|
|
832
|
+
});
|
|
833
|
+
})
|
|
834
|
+
.filter((skill) => skill.name && skill.name !== "Name");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function parseMcpOutput(output) {
|
|
838
|
+
if (/No MCP servers configured/i.test(output)) {
|
|
839
|
+
return [];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return parseBoxRows(output)
|
|
843
|
+
.map((columns) => compactObject({ name: sanitizeDisplayString(columns[0]), status: sanitizeDisplayString(columns[1]), type: sanitizeDisplayString(columns[2]) }))
|
|
844
|
+
.filter((server) => server.name && server.name !== "Name");
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function parseProfilesOutput(output) {
|
|
848
|
+
return output
|
|
849
|
+
.split("\n")
|
|
850
|
+
.map((line) => {
|
|
851
|
+
const trimmed = line.trim();
|
|
852
|
+
|
|
853
|
+
if (!trimmed || /^Profile\s+Model\s+Gateway\s+Alias/i.test(trimmed) || /^[\u2500\s]+$/.test(trimmed)) {
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const active = trimmed.startsWith("\u25c6");
|
|
858
|
+
const normalized = trimmed.replace(/^\u25c6/, "").trim();
|
|
859
|
+
const columns = normalized.split(/\s{2,}/).map((column) => sanitizeDisplayString(column));
|
|
860
|
+
|
|
861
|
+
if (!columns[0]) {
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return compactObject({
|
|
866
|
+
active,
|
|
867
|
+
alias: columns[3] === "\u2014" ? undefined : columns[3],
|
|
868
|
+
gateway: columns[2] === "\u2014" ? undefined : columns[2],
|
|
869
|
+
model: columns[1] === "\u2014" ? undefined : columns[1],
|
|
870
|
+
name: columns[0],
|
|
871
|
+
});
|
|
872
|
+
})
|
|
873
|
+
.filter(Boolean);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function parsePluginsOutput(output) {
|
|
877
|
+
const plugins = [];
|
|
878
|
+
|
|
879
|
+
for (const columns of parseBoxRows(output)) {
|
|
880
|
+
const [name, status, version, description, source] = columns.map((column) => sanitizeDisplayString(column));
|
|
881
|
+
|
|
882
|
+
if (name && name !== "Name") {
|
|
883
|
+
plugins.push(
|
|
884
|
+
compactObject({
|
|
885
|
+
description: sanitizeDisplayString(description, 420),
|
|
886
|
+
name,
|
|
887
|
+
source,
|
|
888
|
+
status,
|
|
889
|
+
version,
|
|
890
|
+
}),
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return plugins;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function parseCronOutput(output) {
|
|
899
|
+
const jobs = [];
|
|
900
|
+
let current;
|
|
901
|
+
|
|
902
|
+
for (const line of output.split("\n")) {
|
|
903
|
+
const header = line.match(/^\s*([a-f0-9]{12})\s+\[([^\]]+)\]/i);
|
|
904
|
+
|
|
905
|
+
if (header) {
|
|
906
|
+
current = {
|
|
907
|
+
id: sanitizeDisplayString(header[1]),
|
|
908
|
+
name: sanitizeDisplayString(header[1]),
|
|
909
|
+
status: sanitizeDisplayString(header[2]),
|
|
910
|
+
};
|
|
911
|
+
jobs.push(current);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (!current) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const field = line.match(/^\s*(Name|Schedule|Next run|Deliver|Script|Last run):\s+(.+)$/);
|
|
920
|
+
const repeatField = line.match(/^\s*Repeat:\s+(.+)$/);
|
|
921
|
+
const skillsField = line.match(/^\s*Skills:\s+(.+)$/);
|
|
922
|
+
|
|
923
|
+
if (repeatField) {
|
|
924
|
+
current.repeat = sanitizeDisplayString(repeatField[1]);
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (skillsField) {
|
|
929
|
+
current.skills = skillsField[1]
|
|
930
|
+
.split(",")
|
|
931
|
+
.map((skill) => sanitizeDisplayString(skill))
|
|
932
|
+
.filter(Boolean);
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (!field) {
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const key = field[1];
|
|
941
|
+
const value = sanitizeDisplayString(field[2]);
|
|
942
|
+
|
|
943
|
+
if (key === "Name") {
|
|
944
|
+
current.name = value;
|
|
945
|
+
} else if (key === "Schedule") {
|
|
946
|
+
current.schedule = value;
|
|
947
|
+
} else if (key === "Next run") {
|
|
948
|
+
current.nextRun = value;
|
|
949
|
+
} else if (key === "Deliver") {
|
|
950
|
+
current.deliverTo = sanitizeDeliveryTarget(value);
|
|
951
|
+
} else if (key === "Script") {
|
|
952
|
+
current.script = value;
|
|
953
|
+
} else if (key === "Last run") {
|
|
954
|
+
current.lastRun = value;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return jobs.filter((job) => job.id && job.name);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function parseBoxRows(output) {
|
|
962
|
+
return output
|
|
963
|
+
.split("\n")
|
|
964
|
+
.filter((line) => line.trim().startsWith("\u2502"))
|
|
965
|
+
.map((line) =>
|
|
966
|
+
line
|
|
967
|
+
.slice(1, -1)
|
|
968
|
+
.split("\u2502")
|
|
969
|
+
.map((column) => sanitizeDisplayString(column)),
|
|
970
|
+
)
|
|
971
|
+
.filter((columns) => columns.some(Boolean));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function persistLocalConfig({ agentosUrl, configDir, dashboardUrl, hermesUrl, mode, pairingCode }) {
|
|
975
|
+
const targetDir = configDir ?? join(homedir(), ".agentos", "hermes");
|
|
976
|
+
const targetPath = join(targetDir, "connection.json");
|
|
977
|
+
const body = {
|
|
978
|
+
agentosUrl,
|
|
979
|
+
createdAt: new Date().toISOString(),
|
|
980
|
+
dashboardUrl,
|
|
981
|
+
hermesUrl,
|
|
982
|
+
mode,
|
|
983
|
+
pairingCode,
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
await mkdir(targetDir, { recursive: true });
|
|
987
|
+
await writeFile(targetPath, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
988
|
+
|
|
989
|
+
return targetPath;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async function postHeartbeat(agentosUrl, pairingCode, payload) {
|
|
993
|
+
const response = await fetch(
|
|
994
|
+
`${agentosUrl}/api/hermes/pairing-sessions/${encodeURIComponent(pairingCode)}/heartbeat`,
|
|
995
|
+
{
|
|
996
|
+
body: JSON.stringify(payload),
|
|
997
|
+
headers: {
|
|
998
|
+
"content-type": "application/json",
|
|
999
|
+
accept: "application/json",
|
|
1000
|
+
},
|
|
1001
|
+
method: "POST",
|
|
1002
|
+
},
|
|
1003
|
+
);
|
|
1004
|
+
const text = await response.text();
|
|
1005
|
+
const body = text ? JSON.parse(text) : {};
|
|
1006
|
+
|
|
1007
|
+
if (!response.ok) {
|
|
1008
|
+
const message = typeof body.error === "string" ? body.error : `AgentOS returned ${response.status}.`;
|
|
1009
|
+
throw new Error(message);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return body;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function runHeartbeatLoop(sendHeartbeat, intervalMs) {
|
|
1016
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
|
1017
|
+
throw new Error("--interval-ms must be at least 1000.");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
let stopping = false;
|
|
1021
|
+
let interval;
|
|
1022
|
+
|
|
1023
|
+
await new Promise((resolve, reject) => {
|
|
1024
|
+
const stop = () => {
|
|
1025
|
+
stopping = true;
|
|
1026
|
+
clearInterval(interval);
|
|
1027
|
+
resolve();
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
process.once("SIGINT", stop);
|
|
1031
|
+
process.once("SIGTERM", stop);
|
|
1032
|
+
|
|
1033
|
+
interval = setInterval(() => {
|
|
1034
|
+
void sendHeartbeat()
|
|
1035
|
+
.then((session) => {
|
|
1036
|
+
if (!stopping) {
|
|
1037
|
+
console.log(`Heartbeat accepted at ${session.lastHeartbeatAt ?? new Date().toISOString()}`);
|
|
1038
|
+
}
|
|
1039
|
+
})
|
|
1040
|
+
.catch((error) => {
|
|
1041
|
+
clearInterval(interval);
|
|
1042
|
+
reject(error);
|
|
1043
|
+
});
|
|
1044
|
+
}, intervalMs);
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function normalizeBaseUrl(value) {
|
|
1049
|
+
return value.replace(/\/$/, "");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function stripAnsi(value) {
|
|
1053
|
+
return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function sanitizeDisplayString(value, maxLength = 220) {
|
|
1057
|
+
if (typeof value !== "string") {
|
|
1058
|
+
return undefined;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const compacted = value.replace(/\s+/g, " ").trim();
|
|
1062
|
+
|
|
1063
|
+
if (!compacted || compacted === "\u2014") {
|
|
1064
|
+
return undefined;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return compacted.slice(0, maxLength);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function parseNumber(value) {
|
|
1071
|
+
if (!value) {
|
|
1072
|
+
return undefined;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const parsed = Number(String(value).replaceAll(",", ""));
|
|
1076
|
+
|
|
1077
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function formatCompactNumber(value) {
|
|
1081
|
+
if (!Number.isFinite(value)) {
|
|
1082
|
+
return "0";
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (value >= 1_000_000) {
|
|
1086
|
+
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 1 : 2)}M`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (value >= 1_000) {
|
|
1090
|
+
return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)}K`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
return String(value);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function escapeRegExp(value) {
|
|
1097
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function sanitizeDeliveryTarget(value) {
|
|
1101
|
+
const safeValue = sanitizeDisplayString(value);
|
|
1102
|
+
|
|
1103
|
+
if (!safeValue) {
|
|
1104
|
+
return undefined;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (safeValue.includes(":")) {
|
|
1108
|
+
return safeValue.split(":")[0];
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return safeValue;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function redactLocalPath(value) {
|
|
1115
|
+
if (!value) {
|
|
1116
|
+
return undefined;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
return value.replaceAll(homedir(), "~");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function redactSensitiveContent(value) {
|
|
1123
|
+
if (!value) {
|
|
1124
|
+
return "";
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return value
|
|
1128
|
+
.replaceAll(homedir(), "~")
|
|
1129
|
+
.replace(
|
|
1130
|
+
/^(\s*[\w.-]*(?:api[_-]?key|token|secret|password|credential|private[_-]?key|auth|cookie|session)[\w.-]*\s*[:=]\s*).+$/gim,
|
|
1131
|
+
"$1[redacted]",
|
|
1132
|
+
)
|
|
1133
|
+
.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, "[redacted-openai-key]")
|
|
1134
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "[redacted-github-token]")
|
|
1135
|
+
.replace(/\bxox[baprs]-[A-Za-z0-9-]{16,}\b/g, "[redacted-slack-token]")
|
|
1136
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted-aws-key]")
|
|
1137
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[redacted-private-key]");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function compactObject(value) {
|
|
1141
|
+
return Object.fromEntries(
|
|
1142
|
+
Object.entries(value).filter(([, entry]) => {
|
|
1143
|
+
if (entry === undefined || entry === null) {
|
|
1144
|
+
return false;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (Array.isArray(entry)) {
|
|
1148
|
+
return entry.length > 0;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return true;
|
|
1152
|
+
}),
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function printHelp() {
|
|
1157
|
+
console.log(`AgentOS Hermes connector ${VERSION}
|
|
1158
|
+
|
|
1159
|
+
Usage:
|
|
1160
|
+
agentos-hermes connect --pair <code> [options]
|
|
1161
|
+
|
|
1162
|
+
Commands:
|
|
1163
|
+
connect Pair the local Hermes agent with AgentOS
|
|
1164
|
+
`);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function printConnectHelp() {
|
|
1168
|
+
console.log(`Usage:
|
|
1169
|
+
agentos-hermes connect --pair <code> [options]
|
|
1170
|
+
|
|
1171
|
+
Options:
|
|
1172
|
+
--agentos-url <url> AgentOS web app URL. Defaults to ${DEFAULT_AGENTOS_URL}
|
|
1173
|
+
--hermes-url <url> Local Hermes gateway URL. Defaults to ${DEFAULT_HERMES_URL}
|
|
1174
|
+
--dashboard-url <url> Optional Hermes dashboard URL, often http://127.0.0.1:9119
|
|
1175
|
+
--mode <mode> plugin, sidecar, or direct-url. Defaults to ${DEFAULT_MODE}
|
|
1176
|
+
--config-dir <path> Local config directory. Defaults to ~/.agentos/hermes
|
|
1177
|
+
--hermes-command <cmd> Hermes CLI executable. Defaults to ${DEFAULT_HERMES_COMMAND}
|
|
1178
|
+
--interval-ms <ms> Heartbeat interval. Defaults to 15000
|
|
1179
|
+
--skip-inventory Send basic heartbeat metadata only
|
|
1180
|
+
--once Send one heartbeat and exit, useful for CI and smoke tests
|
|
1181
|
+
`);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
1185
|
+
console.error(error instanceof Error ? error.message : error);
|
|
1186
|
+
process.exitCode = 1;
|
|
1187
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentworkspaceos/hermes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AgentOS connector CLI for pairing a local Hermes runtime with an AgentOS workspace.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agentos-hermes": "bin/agentos-hermes.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/agentos-hermes.js",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"cli": "node bin/agentos-hermes.js",
|
|
15
|
+
"test": "node --test tests/**/*.test.js",
|
|
16
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
17
|
+
"publish:public": "npm publish --access public"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"agentos",
|
|
21
|
+
"hermes",
|
|
22
|
+
"connector",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.0.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
}
|
|
31
|
+
}
|