@hermespilot/link 0.1.5 → 0.1.7
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 +8 -1
- package/dist/chunk-SCIAZZ4C.js +2992 -0
- package/dist/chunk-SCIAZZ4C.js.map +1 -0
- package/dist/cli/index.js +16 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/http/app.d.ts +3 -0
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-E54NSFGF.js +0 -1652
- package/dist/chunk-E54NSFGF.js.map +0 -1
|
@@ -0,0 +1,2992 @@
|
|
|
1
|
+
// src/http/app.ts
|
|
2
|
+
import Koa from "koa";
|
|
3
|
+
import Router from "@koa/router";
|
|
4
|
+
import { Readable } from "stream";
|
|
5
|
+
|
|
6
|
+
// src/constants.ts
|
|
7
|
+
var LINK_VERSION = "0.1.7";
|
|
8
|
+
var LINK_COMMAND = "hermeslink";
|
|
9
|
+
var LINK_DEFAULT_PORT = 52379;
|
|
10
|
+
var LINK_RUNTIME_DIR_NAME = ".hermeslink";
|
|
11
|
+
|
|
12
|
+
// src/runtime/paths.ts
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
function resolveRuntimeHome() {
|
|
16
|
+
return process.env.HERMESLINK_HOME?.trim() ? path.resolve(process.env.HERMESLINK_HOME) : path.join(os.homedir(), LINK_RUNTIME_DIR_NAME);
|
|
17
|
+
}
|
|
18
|
+
function resolveRuntimePaths(homeDir = resolveRuntimeHome()) {
|
|
19
|
+
return {
|
|
20
|
+
homeDir,
|
|
21
|
+
identityFile: path.join(homeDir, "identity.json"),
|
|
22
|
+
configFile: path.join(homeDir, "config.json"),
|
|
23
|
+
stateFile: path.join(homeDir, "state.json"),
|
|
24
|
+
credentialsFile: path.join(homeDir, "credentials.json"),
|
|
25
|
+
databaseFile: path.join(homeDir, "link.db"),
|
|
26
|
+
conversationsDir: path.join(homeDir, "conversations"),
|
|
27
|
+
blobsDir: path.join(homeDir, "blobs"),
|
|
28
|
+
indexesDir: path.join(homeDir, "indexes"),
|
|
29
|
+
logsDir: path.join(homeDir, "logs"),
|
|
30
|
+
runDir: path.join(homeDir, "run"),
|
|
31
|
+
pairingDir: path.join(homeDir, "pairing")
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/storage/atomic-json.ts
|
|
36
|
+
import { mkdir, open, readFile, rename, rm } from "fs/promises";
|
|
37
|
+
import path2 from "path";
|
|
38
|
+
async function readJsonFile(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readFile(filePath, "utf8");
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isNodeError(error, "ENOENT")) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function writeJsonFile(filePath, value, mode = 384) {
|
|
50
|
+
await mkdir(path2.dirname(filePath), { recursive: true, mode: 448 });
|
|
51
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
52
|
+
const payload = `${JSON.stringify(value, null, 2)}
|
|
53
|
+
`;
|
|
54
|
+
const handle = await open(tmpPath, "w", mode);
|
|
55
|
+
try {
|
|
56
|
+
await handle.writeFile(payload, "utf8");
|
|
57
|
+
await handle.sync();
|
|
58
|
+
} finally {
|
|
59
|
+
await handle.close();
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await rename(tmpPath, filePath);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
await rm(tmpPath, { force: true });
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function isNodeError(error, code) {
|
|
69
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/config/config.ts
|
|
73
|
+
var defaultLinkConfig = {
|
|
74
|
+
port: LINK_DEFAULT_PORT,
|
|
75
|
+
serverBaseUrl: "https://hermes-server.clawpilot.me",
|
|
76
|
+
relayBaseUrl: "https://hermes-relay.clawpilot.me",
|
|
77
|
+
appConnectTokenIssuer: "https://hermes-server.clawpilot.me",
|
|
78
|
+
appConnectTokenAudience: "hermes-link",
|
|
79
|
+
language: "auto"
|
|
80
|
+
};
|
|
81
|
+
async function loadConfig(paths = resolveRuntimePaths()) {
|
|
82
|
+
const existing = await readJsonFile(paths.configFile);
|
|
83
|
+
const language = normalizeConfiguredLanguage(existing?.language);
|
|
84
|
+
return {
|
|
85
|
+
...defaultLinkConfig,
|
|
86
|
+
...existing ?? {},
|
|
87
|
+
language
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function normalizeConfiguredLanguage(language) {
|
|
91
|
+
if (language === "zh-CN" || language === "en" || language === "auto") {
|
|
92
|
+
return language;
|
|
93
|
+
}
|
|
94
|
+
return defaultLinkConfig.language;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/conversations/conversation-service.ts
|
|
98
|
+
import { EventEmitter } from "events";
|
|
99
|
+
import { appendFile, mkdir as mkdir4, readdir, readFile as readFile3, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
100
|
+
import path5 from "path";
|
|
101
|
+
import { createHash, randomUUID } from "crypto";
|
|
102
|
+
|
|
103
|
+
// src/http/errors.ts
|
|
104
|
+
var LinkHttpError = class extends Error {
|
|
105
|
+
constructor(status, code, message) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.status = status;
|
|
108
|
+
this.code = code;
|
|
109
|
+
}
|
|
110
|
+
status;
|
|
111
|
+
code;
|
|
112
|
+
};
|
|
113
|
+
function isLinkHttpError(error) {
|
|
114
|
+
return error instanceof LinkHttpError;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/hermes/gateway.ts
|
|
118
|
+
import { execFile as execFile2, spawn } from "child_process";
|
|
119
|
+
import { mkdir as mkdir3, open as open2 } from "fs/promises";
|
|
120
|
+
import path4 from "path";
|
|
121
|
+
import { setTimeout as delay } from "timers/promises";
|
|
122
|
+
import { promisify as promisify2 } from "util";
|
|
123
|
+
|
|
124
|
+
// src/hermes/config.ts
|
|
125
|
+
import { randomBytes } from "crypto";
|
|
126
|
+
import { copyFile, mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
|
|
127
|
+
import os2 from "os";
|
|
128
|
+
import path3 from "path";
|
|
129
|
+
import YAML from "yaml";
|
|
130
|
+
var DEFAULT_HERMES_API_SERVER_HOST = "127.0.0.1";
|
|
131
|
+
var DEFAULT_HERMES_API_SERVER_PORT = 8642;
|
|
132
|
+
function resolveHermesProfileDir(profileName = "default") {
|
|
133
|
+
if (profileName === "default") {
|
|
134
|
+
return path3.join(os2.homedir(), ".hermes");
|
|
135
|
+
}
|
|
136
|
+
return path3.join(os2.homedir(), ".hermes", "profiles", profileName);
|
|
137
|
+
}
|
|
138
|
+
function resolveHermesConfigPath(profileName = "default") {
|
|
139
|
+
return path3.join(resolveHermesProfileDir(profileName), "config.yaml");
|
|
140
|
+
}
|
|
141
|
+
async function readHermesApiServerConfig(profileName = "default", configPath = resolveHermesConfigPath(profileName)) {
|
|
142
|
+
const existingRaw = await readFile2(configPath, "utf8").catch((error) => {
|
|
143
|
+
if (isNodeError2(error, "ENOENT")) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
});
|
|
148
|
+
if (!existingRaw) {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
const config = toRecord(YAML.parse(existingRaw));
|
|
152
|
+
const platforms = toRecord(config.platforms);
|
|
153
|
+
const apiServer = toRecord(platforms.api_server);
|
|
154
|
+
return readApiServerConfig(apiServer, true);
|
|
155
|
+
}
|
|
156
|
+
async function ensureHermesApiServerKey(profileName = "default", configPath = resolveHermesConfigPath(profileName)) {
|
|
157
|
+
return ensureHermesApiServerConfig(profileName, configPath);
|
|
158
|
+
}
|
|
159
|
+
async function ensureHermesApiServerConfig(profileName = "default", configPath = resolveHermesConfigPath(profileName)) {
|
|
160
|
+
const existingRaw = await readFile2(configPath, "utf8").catch((error) => {
|
|
161
|
+
if (isNodeError2(error, "ENOENT")) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
});
|
|
166
|
+
const document = existingRaw ? YAML.parseDocument(existingRaw) : new YAML.Document({});
|
|
167
|
+
const config = toRecord(document.toJSON());
|
|
168
|
+
const platforms = ensureRecord(config, "platforms");
|
|
169
|
+
const apiServer = ensureRecord(platforms, "api_server");
|
|
170
|
+
const extra = ensureRecord(apiServer, "extra");
|
|
171
|
+
const beforeKey = typeof extra.key === "string" && extra.key.length > 0 ? extra.key : null;
|
|
172
|
+
const beforeEnabled = apiServer.enabled === true;
|
|
173
|
+
const beforeHost = typeof extra.host === "string" && extra.host.trim() ? extra.host : null;
|
|
174
|
+
const beforePort = typeof extra.port === "number" && Number.isFinite(extra.port) ? extra.port : null;
|
|
175
|
+
let changed = false;
|
|
176
|
+
let enabledAdded = false;
|
|
177
|
+
let hostAdded = false;
|
|
178
|
+
let portAdded = false;
|
|
179
|
+
if (!beforeEnabled) {
|
|
180
|
+
apiServer.enabled = true;
|
|
181
|
+
enabledAdded = true;
|
|
182
|
+
changed = true;
|
|
183
|
+
}
|
|
184
|
+
if (!beforeHost) {
|
|
185
|
+
extra.host = DEFAULT_HERMES_API_SERVER_HOST;
|
|
186
|
+
hostAdded = true;
|
|
187
|
+
changed = true;
|
|
188
|
+
}
|
|
189
|
+
if (!beforePort) {
|
|
190
|
+
extra.port = DEFAULT_HERMES_API_SERVER_PORT;
|
|
191
|
+
portAdded = true;
|
|
192
|
+
changed = true;
|
|
193
|
+
}
|
|
194
|
+
if (!beforeKey) {
|
|
195
|
+
extra.key = randomBytes(32).toString("base64url");
|
|
196
|
+
changed = true;
|
|
197
|
+
}
|
|
198
|
+
if (!changed) {
|
|
199
|
+
return {
|
|
200
|
+
configPath,
|
|
201
|
+
apiServer: readApiServerConfig(apiServer, true),
|
|
202
|
+
changed: false,
|
|
203
|
+
keyAdded: false,
|
|
204
|
+
enabledAdded: false,
|
|
205
|
+
hostAdded: false,
|
|
206
|
+
portAdded: false,
|
|
207
|
+
backupPath: null,
|
|
208
|
+
notice: null
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const backupPath = existingRaw ? `${configPath}.bak.${Date.now()}` : null;
|
|
212
|
+
await mkdir2(path3.dirname(configPath), { recursive: true, mode: 448 });
|
|
213
|
+
if (backupPath) {
|
|
214
|
+
await copyFile(configPath, backupPath);
|
|
215
|
+
}
|
|
216
|
+
document.contents = document.createNode(config);
|
|
217
|
+
await writeFile(configPath, document.toString(), { mode: 384 });
|
|
218
|
+
return {
|
|
219
|
+
configPath,
|
|
220
|
+
apiServer: readApiServerConfig(apiServer, true),
|
|
221
|
+
changed: true,
|
|
222
|
+
keyAdded: !beforeKey,
|
|
223
|
+
enabledAdded,
|
|
224
|
+
hostAdded,
|
|
225
|
+
portAdded,
|
|
226
|
+
backupPath,
|
|
227
|
+
notice: buildNotice({ keyAdded: !beforeKey, enabledAdded, hostAdded, portAdded })
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function readApiServerConfig(apiServerOrExtra, withDefaults = false) {
|
|
231
|
+
const apiServer = "extra" in apiServerOrExtra ? apiServerOrExtra : {};
|
|
232
|
+
const extra = toRecord("extra" in apiServerOrExtra ? apiServerOrExtra.extra : apiServerOrExtra);
|
|
233
|
+
const port = typeof extra.port === "number" ? extra.port : void 0;
|
|
234
|
+
const host = typeof extra.host === "string" ? extra.host : void 0;
|
|
235
|
+
return {
|
|
236
|
+
enabled: apiServer.enabled === true,
|
|
237
|
+
host: withDefaults ? host ?? DEFAULT_HERMES_API_SERVER_HOST : host,
|
|
238
|
+
port: withDefaults ? port ?? DEFAULT_HERMES_API_SERVER_PORT : port,
|
|
239
|
+
key: typeof extra.key === "string" ? extra.key : void 0
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function buildNotice(flags) {
|
|
243
|
+
const fields = [];
|
|
244
|
+
if (flags.enabledAdded) {
|
|
245
|
+
fields.push("enabled");
|
|
246
|
+
}
|
|
247
|
+
if (flags.hostAdded) {
|
|
248
|
+
fields.push("host=127.0.0.1");
|
|
249
|
+
}
|
|
250
|
+
if (flags.portAdded) {
|
|
251
|
+
fields.push("port=8642");
|
|
252
|
+
}
|
|
253
|
+
if (flags.keyAdded) {
|
|
254
|
+
fields.push("key");
|
|
255
|
+
}
|
|
256
|
+
return `\u5DF2\u4E3A Hermes API Server \u81EA\u52A8\u8865\u5145 ${fields.join("\u3001")}\uFF1B\u672A\u8986\u76D6\u5DF2\u6709 port/host/key\u3002`;
|
|
257
|
+
}
|
|
258
|
+
function toRecord(value) {
|
|
259
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
260
|
+
}
|
|
261
|
+
function ensureRecord(parent, key) {
|
|
262
|
+
const value = parent[key];
|
|
263
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
const next = {};
|
|
267
|
+
parent[key] = next;
|
|
268
|
+
return next;
|
|
269
|
+
}
|
|
270
|
+
function isNodeError2(error, code) {
|
|
271
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/hermes/cli.ts
|
|
275
|
+
import { execFile } from "child_process";
|
|
276
|
+
import { promisify } from "util";
|
|
277
|
+
var execFileAsync = promisify(execFile);
|
|
278
|
+
async function deleteHermesSession(sessionId) {
|
|
279
|
+
if (!sessionId.trim()) {
|
|
280
|
+
throw new LinkHttpError(400, "hermes_session_id_required", "Hermes session id is required");
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await execFileAsync(resolveHermesBin(), ["sessions", "delete", sessionId, "--yes"], {
|
|
284
|
+
timeout: 1e4,
|
|
285
|
+
windowsHide: true
|
|
286
|
+
});
|
|
287
|
+
} catch (error) {
|
|
288
|
+
throw new LinkHttpError(
|
|
289
|
+
502,
|
|
290
|
+
"hermes_session_delete_failed",
|
|
291
|
+
error instanceof Error ? error.message : "Hermes session delete failed"
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function resolveHermesBin() {
|
|
296
|
+
return process.env.HERMES_BIN?.trim() || "hermes";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/hermes/gateway.ts
|
|
300
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
301
|
+
var DEFAULT_START_TIMEOUT_MS = 12e3;
|
|
302
|
+
var HEALTH_TIMEOUT_MS = 1500;
|
|
303
|
+
var MIN_API_SERVER_VERSION = "0.4.0";
|
|
304
|
+
var gatewayStartInFlight = null;
|
|
305
|
+
async function ensureHermesApiServerAvailable(options = {}) {
|
|
306
|
+
const configResult = await ensureHermesApiServerConfig();
|
|
307
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
308
|
+
if (await isHermesApiHealthy(configResult.apiServer, fetcher)) {
|
|
309
|
+
return { available: true, configResult, started: false };
|
|
310
|
+
}
|
|
311
|
+
if (!shouldAutoStart(options.autoStart)) {
|
|
312
|
+
throw new LinkHttpError(503, "hermes_api_server_unavailable", unavailableMessage());
|
|
313
|
+
}
|
|
314
|
+
const start = await startHermesGatewayOnce(options.paths ?? resolveRuntimePaths());
|
|
315
|
+
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_START_TIMEOUT_MS);
|
|
316
|
+
while (Date.now() < deadline) {
|
|
317
|
+
if (await isHermesApiHealthy(configResult.apiServer, fetcher)) {
|
|
318
|
+
return { available: true, configResult, started: true, start };
|
|
319
|
+
}
|
|
320
|
+
await delay(400);
|
|
321
|
+
}
|
|
322
|
+
throw new LinkHttpError(
|
|
323
|
+
503,
|
|
324
|
+
"hermes_api_server_unavailable",
|
|
325
|
+
`${unavailableMessage()} Link tried to start it with \`${resolveHermesBin()} gateway run --replace\`, but it did not become ready. Gateway log: ${start.logPath}`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
async function readHermesVersion() {
|
|
329
|
+
const { stdout } = await execHermesVersion();
|
|
330
|
+
const raw = stdout.trim();
|
|
331
|
+
const version = parseHermesVersion(raw);
|
|
332
|
+
return {
|
|
333
|
+
raw,
|
|
334
|
+
version,
|
|
335
|
+
supportsApiServer: version ? compareSemver(version, MIN_API_SERVER_VERSION) >= 0 : null
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async function execHermesVersion() {
|
|
339
|
+
try {
|
|
340
|
+
return await execFileAsync2(resolveHermesBin(), ["version"], {
|
|
341
|
+
timeout: 5e3,
|
|
342
|
+
windowsHide: true
|
|
343
|
+
});
|
|
344
|
+
} catch {
|
|
345
|
+
return await execFileAsync2(resolveHermesBin(), ["--version"], {
|
|
346
|
+
timeout: 5e3,
|
|
347
|
+
windowsHide: true
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function assertHermesRunsApiSupported(version, status) {
|
|
352
|
+
if (status !== 404) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const versionText = version?.version ? `\u5F53\u524D\u68C0\u6D4B\u5230 Hermes Agent ${version.version}\u3002` : "";
|
|
356
|
+
throw new LinkHttpError(
|
|
357
|
+
502,
|
|
358
|
+
"hermes_runs_api_unsupported",
|
|
359
|
+
`${versionText}\u5F53\u524D Hermes Agent API Server \u4E0D\u652F\u6301 HermesPilot \u9700\u8981\u7684 /v1/runs \u63A5\u53E3\uFF0C\u8BF7\u5148\u8FD0\u884C \`hermes update\` \u5347\u7EA7 Hermes Agent\u3002`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
async function startHermesGatewayOnce(paths) {
|
|
363
|
+
if (!gatewayStartInFlight) {
|
|
364
|
+
gatewayStartInFlight = startHermesGateway(paths).finally(() => {
|
|
365
|
+
gatewayStartInFlight = null;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return await gatewayStartInFlight;
|
|
369
|
+
}
|
|
370
|
+
async function startHermesGateway(paths) {
|
|
371
|
+
const version = await readHermesVersion().catch((error) => {
|
|
372
|
+
throw new LinkHttpError(
|
|
373
|
+
503,
|
|
374
|
+
"hermes_agent_not_available",
|
|
375
|
+
`\u6CA1\u6709\u627E\u5230\u53EF\u7528\u7684 Hermes Agent CLI\u3002\u8BF7\u5148\u5B89\u88C5 Hermes Agent\uFF0C\u6216\u8BBE\u7F6E HERMES_BIN\u3002${formatErrorSuffix(error)}`
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
if (version.supportsApiServer === false) {
|
|
379
|
+
throw new LinkHttpError(
|
|
380
|
+
503,
|
|
381
|
+
"hermes_agent_too_old",
|
|
382
|
+
`\u5F53\u524D Hermes Agent ${version.version} \u592A\u65E7\uFF0C\u53EF\u80FD\u4E0D\u652F\u6301 Hermes API Server\u3002\u8BF7\u5148\u8FD0\u884C \`hermes update\` \u5347\u7EA7\u3002`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
await mkdir3(paths.logsDir, { recursive: true, mode: 448 });
|
|
386
|
+
const logPath = path4.join(paths.logsDir, "hermes-gateway.log");
|
|
387
|
+
const logFile = await open2(logPath, "a", 384);
|
|
388
|
+
try {
|
|
389
|
+
const child = spawn(resolveHermesBin(), ["gateway", "run", "--replace"], {
|
|
390
|
+
detached: true,
|
|
391
|
+
env: process.env,
|
|
392
|
+
stdio: ["ignore", logFile.fd, logFile.fd],
|
|
393
|
+
windowsHide: true
|
|
394
|
+
});
|
|
395
|
+
await new Promise((resolve, reject) => {
|
|
396
|
+
let settled = false;
|
|
397
|
+
const finish = (callback) => {
|
|
398
|
+
if (settled) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
settled = true;
|
|
402
|
+
callback();
|
|
403
|
+
};
|
|
404
|
+
child.once("spawn", () => finish(resolve));
|
|
405
|
+
child.once("error", (error) => finish(() => reject(error)));
|
|
406
|
+
});
|
|
407
|
+
child.unref();
|
|
408
|
+
return { pid: child.pid ?? null, logPath, version };
|
|
409
|
+
} finally {
|
|
410
|
+
await logFile.close().catch(() => void 0);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function isHermesApiHealthy(config, fetcher) {
|
|
414
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${config.port}/health`, {
|
|
415
|
+
method: "GET",
|
|
416
|
+
headers: authHeaders(config)
|
|
417
|
+
}, fetcher);
|
|
418
|
+
return Boolean(response?.ok);
|
|
419
|
+
}
|
|
420
|
+
function fetchWithTimeout(input, init, fetcher) {
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
423
|
+
return fetcher(input, { ...init, signal: controller.signal }).catch(() => null).finally(() => clearTimeout(timer));
|
|
424
|
+
}
|
|
425
|
+
function authHeaders(config) {
|
|
426
|
+
const headers = new Headers();
|
|
427
|
+
headers.set("accept", "application/json");
|
|
428
|
+
if (config.key) {
|
|
429
|
+
headers.set("x-api-key", config.key);
|
|
430
|
+
headers.set("authorization", `Bearer ${config.key}`);
|
|
431
|
+
}
|
|
432
|
+
return headers;
|
|
433
|
+
}
|
|
434
|
+
function shouldAutoStart(value) {
|
|
435
|
+
if (value !== void 0) {
|
|
436
|
+
return value;
|
|
437
|
+
}
|
|
438
|
+
const raw = process.env.HERMESLINK_GATEWAY_AUTOSTART?.trim().toLowerCase();
|
|
439
|
+
return raw !== "0" && raw !== "false" && raw !== "off";
|
|
440
|
+
}
|
|
441
|
+
function unavailableMessage() {
|
|
442
|
+
return "Hermes API Server \u5F53\u524D\u4E0D\u53EF\u7528\u3002\u8BF7\u786E\u8BA4 Hermes Agent \u5DF2\u5B89\u88C5\uFF0C\u5E76\u53EF\u901A\u8FC7 `hermes gateway run` \u542F\u52A8\uFF1BHermesPilot Link \u9700\u8981 Hermes Agent \u7684\u672C\u673A API Server\u3002";
|
|
443
|
+
}
|
|
444
|
+
function parseHermesVersion(value) {
|
|
445
|
+
const match = /\bv?(\d+\.\d+\.\d+)\b/u.exec(value);
|
|
446
|
+
return match?.[1] ?? null;
|
|
447
|
+
}
|
|
448
|
+
function compareSemver(left, right) {
|
|
449
|
+
const leftParts = left.split(".").map((part) => Number.parseInt(part, 10));
|
|
450
|
+
const rightParts = right.split(".").map((part) => Number.parseInt(part, 10));
|
|
451
|
+
for (let index = 0; index < 3; index += 1) {
|
|
452
|
+
const diff = (leftParts[index] || 0) - (rightParts[index] || 0);
|
|
453
|
+
if (diff !== 0) {
|
|
454
|
+
return diff;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
function formatErrorSuffix(error) {
|
|
460
|
+
if (!(error instanceof Error) || !error.message) {
|
|
461
|
+
return "";
|
|
462
|
+
}
|
|
463
|
+
return ` \u539F\u59CB\u9519\u8BEF\uFF1A${error.message}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/hermes/api-server.ts
|
|
467
|
+
async function listHermesModels(options = {}) {
|
|
468
|
+
const response = await callHermesApi("/v1/models", { method: "GET" }, options);
|
|
469
|
+
if (response.status === 404) {
|
|
470
|
+
return { models: [] };
|
|
471
|
+
}
|
|
472
|
+
return await readJsonResponse(response);
|
|
473
|
+
}
|
|
474
|
+
async function createHermesRun(input, options = {}) {
|
|
475
|
+
const response = await callHermesApi(
|
|
476
|
+
"/v1/runs",
|
|
477
|
+
{
|
|
478
|
+
method: "POST",
|
|
479
|
+
body: JSON.stringify(input),
|
|
480
|
+
headers: { "content-type": "application/json" }
|
|
481
|
+
},
|
|
482
|
+
options
|
|
483
|
+
);
|
|
484
|
+
if (response.status === 404 || response.status === 503) {
|
|
485
|
+
assertHermesRunsApiSupported(await readHermesVersion().catch(() => null), response.status);
|
|
486
|
+
throw new LinkHttpError(503, "hermes_api_server_unavailable", "Hermes API Server is unavailable");
|
|
487
|
+
}
|
|
488
|
+
const payload = await readJsonResponse(response);
|
|
489
|
+
const runId = readString(payload, "run_id") ?? readString(payload, "runId") ?? readString(payload, "id");
|
|
490
|
+
if (!runId) {
|
|
491
|
+
throw new LinkHttpError(502, "hermes_run_invalid", "Hermes API Server did not return a run id");
|
|
492
|
+
}
|
|
493
|
+
return { run_id: runId, fallback: false };
|
|
494
|
+
}
|
|
495
|
+
async function streamHermesRunEvents(runId, options = {}) {
|
|
496
|
+
const response = await callHermesApi(`/v1/runs/${encodeURIComponent(runId)}/events`, { method: "GET" }, options);
|
|
497
|
+
assertHermesRunsApiSupported(await readHermesVersion().catch(() => null), response.status);
|
|
498
|
+
if (!response.ok || !response.body) {
|
|
499
|
+
throw new LinkHttpError(502, "hermes_events_unavailable", "Hermes run event stream is unavailable");
|
|
500
|
+
}
|
|
501
|
+
return new Response(response.body, {
|
|
502
|
+
status: response.status,
|
|
503
|
+
headers: {
|
|
504
|
+
"content-type": response.headers.get("content-type") ?? "text/event-stream; charset=utf-8",
|
|
505
|
+
"cache-control": "no-store"
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
async function cancelHermesRun(runId, options = {}) {
|
|
510
|
+
const response = await callHermesApi(
|
|
511
|
+
`/v1/runs/${encodeURIComponent(runId)}/cancel`,
|
|
512
|
+
{ method: "POST" },
|
|
513
|
+
options
|
|
514
|
+
);
|
|
515
|
+
if (!response.ok && response.status !== 404) {
|
|
516
|
+
throw new LinkHttpError(502, "hermes_cancel_failed", "Hermes run cancel failed");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function callHermesApi(path9, init, options) {
|
|
520
|
+
const availability = await ensureHermesApiServerAvailable({ fetchImpl: options.fetchImpl });
|
|
521
|
+
const config = availability.configResult.apiServer;
|
|
522
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
523
|
+
const headers = new Headers(init.headers);
|
|
524
|
+
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
525
|
+
if (config.key) {
|
|
526
|
+
headers.set("x-api-key", config.key);
|
|
527
|
+
headers.set("authorization", `Bearer ${config.key}`);
|
|
528
|
+
}
|
|
529
|
+
return await fetcher(`http://127.0.0.1:${config.port}${path9}`, {
|
|
530
|
+
...init,
|
|
531
|
+
headers
|
|
532
|
+
}).catch(() => {
|
|
533
|
+
throw new LinkHttpError(503, "hermes_api_server_unavailable", "Hermes API Server is unavailable");
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
async function readJsonResponse(response) {
|
|
537
|
+
const payload = await response.json().catch(() => null);
|
|
538
|
+
if (!response.ok || typeof payload !== "object" || payload === null) {
|
|
539
|
+
throw new LinkHttpError(502, "hermes_response_invalid", "Hermes API Server returned an invalid response");
|
|
540
|
+
}
|
|
541
|
+
return payload;
|
|
542
|
+
}
|
|
543
|
+
function readString(payload, key) {
|
|
544
|
+
const value = payload[key];
|
|
545
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/conversations/conversation-service.ts
|
|
549
|
+
var MAX_IMPORTED_BLOB_BYTES = 100 * 1024 * 1024;
|
|
550
|
+
var MAX_UPLOADED_BLOB_BYTES = 50 * 1024 * 1024;
|
|
551
|
+
var CONVERSATION_ID_PATTERN = /^conv_[a-f0-9]{32}$/u;
|
|
552
|
+
var BLOB_ID_PATTERN = /^blob_[a-f0-9]{32}$/u;
|
|
553
|
+
var MEDIA_TAG_PATTERN = /[`"']?MEDIA:\s*(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~\/|\/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|heic|pdf|txt|md|json|csv|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?/giu;
|
|
554
|
+
var ConversationService = class {
|
|
555
|
+
constructor(paths, logger) {
|
|
556
|
+
this.paths = paths;
|
|
557
|
+
this.logger = logger;
|
|
558
|
+
this.emitter.setMaxListeners(0);
|
|
559
|
+
}
|
|
560
|
+
paths;
|
|
561
|
+
logger;
|
|
562
|
+
emitter = new EventEmitter();
|
|
563
|
+
async listConversations() {
|
|
564
|
+
await mkdir4(this.paths.conversationsDir, { recursive: true, mode: 448 });
|
|
565
|
+
const entries = await readdir(this.paths.conversationsDir, { withFileTypes: true }).catch((error) => {
|
|
566
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
throw error;
|
|
570
|
+
});
|
|
571
|
+
const summaries = [];
|
|
572
|
+
for (const entry of entries) {
|
|
573
|
+
if (!entry.isDirectory()) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const manifest = await this.readManifest(entry.name).catch(() => null);
|
|
577
|
+
if (!manifest || manifest.status !== "active") {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const snapshot = await this.readSnapshot(entry.name).catch(() => emptySnapshot());
|
|
581
|
+
summaries.push(toSummary(manifest, snapshot));
|
|
582
|
+
}
|
|
583
|
+
return summaries.sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at));
|
|
584
|
+
}
|
|
585
|
+
async createConversation(input = {}) {
|
|
586
|
+
await mkdir4(this.paths.conversationsDir, { recursive: true, mode: 448 });
|
|
587
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
588
|
+
const id = `conv_${randomUUID().replaceAll("-", "")}`;
|
|
589
|
+
const title = input.title?.trim() || "Untitled";
|
|
590
|
+
const manifest = {
|
|
591
|
+
id,
|
|
592
|
+
schema_version: 1,
|
|
593
|
+
kind: "direct",
|
|
594
|
+
title,
|
|
595
|
+
status: "active",
|
|
596
|
+
hermes_session_id: `hp_${id}`,
|
|
597
|
+
created_at: now,
|
|
598
|
+
updated_at: now,
|
|
599
|
+
last_event_seq: 0
|
|
600
|
+
};
|
|
601
|
+
await mkdir4(this.conversationDir(id), { recursive: true, mode: 448 });
|
|
602
|
+
await this.writeManifest(manifest);
|
|
603
|
+
await this.writeSnapshot(id, emptySnapshot());
|
|
604
|
+
const event = await this.appendEvent(manifest.id, {
|
|
605
|
+
type: "conversation.created",
|
|
606
|
+
payload: { title }
|
|
607
|
+
});
|
|
608
|
+
return { ...toSummary({ ...manifest, last_event_seq: event.seq }, emptySnapshot()), last_event_seq: event.seq };
|
|
609
|
+
}
|
|
610
|
+
async getMessages(conversationId) {
|
|
611
|
+
const manifest = await this.readActiveManifest(conversationId);
|
|
612
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
613
|
+
return {
|
|
614
|
+
messages: snapshot.messages,
|
|
615
|
+
last_event_seq: manifest.last_event_seq
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
async listEvents(conversationId, after = 0) {
|
|
619
|
+
await this.readManifest(conversationId);
|
|
620
|
+
const eventsPath = this.eventsPath(conversationId);
|
|
621
|
+
const raw = await readFile3(eventsPath, "utf8").catch((error) => {
|
|
622
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
623
|
+
return "";
|
|
624
|
+
}
|
|
625
|
+
throw error;
|
|
626
|
+
});
|
|
627
|
+
return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((event) => event.seq > after);
|
|
628
|
+
}
|
|
629
|
+
subscribe(conversationId, listener) {
|
|
630
|
+
const eventName = this.liveEventName(conversationId);
|
|
631
|
+
this.emitter.on(eventName, listener);
|
|
632
|
+
return () => this.emitter.off(eventName, listener);
|
|
633
|
+
}
|
|
634
|
+
async sendMessage(input) {
|
|
635
|
+
const manifest = await this.readActiveManifest(input.conversationId);
|
|
636
|
+
const content = input.content.trim();
|
|
637
|
+
const userAttachmentParts = await this.resolveMessageAttachmentParts(
|
|
638
|
+
manifest.id,
|
|
639
|
+
input.attachments ?? []
|
|
640
|
+
);
|
|
641
|
+
if (!content && userAttachmentParts.length === 0) {
|
|
642
|
+
throw new LinkHttpError(400, "message_content_required", "message content is required");
|
|
643
|
+
}
|
|
644
|
+
const idempotencyKey = input.clientMessageId ?? input.idempotencyKey;
|
|
645
|
+
const snapshot = await this.readSnapshot(input.conversationId);
|
|
646
|
+
if (idempotencyKey) {
|
|
647
|
+
const existingUser = snapshot.messages.find((message) => message.client_message_id === idempotencyKey);
|
|
648
|
+
if (existingUser) {
|
|
649
|
+
const existingRun = snapshot.runs.find((run2) => run2.trigger_message_id === existingUser.id);
|
|
650
|
+
const existingAssistant = existingRun ? snapshot.messages.find((message) => message.id === existingRun.assistant_message_id) : null;
|
|
651
|
+
return {
|
|
652
|
+
conversation_id: manifest.id,
|
|
653
|
+
user_message: { id: existingUser.id, status: existingUser.status },
|
|
654
|
+
assistant_message: { id: existingAssistant?.id ?? "", status: existingAssistant?.status ?? "failed" },
|
|
655
|
+
run: { id: existingRun?.id ?? "", status: existingRun?.status ?? "unknown" },
|
|
656
|
+
last_event_seq: manifest.last_event_seq
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
661
|
+
const userMessage = {
|
|
662
|
+
id: `msg_${randomUUID().replaceAll("-", "")}`,
|
|
663
|
+
schema_version: 1,
|
|
664
|
+
conversation_id: manifest.id,
|
|
665
|
+
role: "user",
|
|
666
|
+
status: "completed",
|
|
667
|
+
client_message_id: idempotencyKey,
|
|
668
|
+
created_at: now,
|
|
669
|
+
updated_at: now,
|
|
670
|
+
sender: { id: "app_user", type: "human", display_name: "Me" },
|
|
671
|
+
parts: [{ type: "text", text: content }, ...userAttachmentParts],
|
|
672
|
+
attachments: userAttachmentParts.map((part) => ({
|
|
673
|
+
blob_id: part.blob,
|
|
674
|
+
mime: part.mime,
|
|
675
|
+
size: part.size,
|
|
676
|
+
filename: part.filename
|
|
677
|
+
})),
|
|
678
|
+
raw: { format: "hermes-link-user-message", payload: { content, attachments: input.attachments ?? [] } }
|
|
679
|
+
};
|
|
680
|
+
const assistantMessage = {
|
|
681
|
+
id: `msg_${randomUUID().replaceAll("-", "")}`,
|
|
682
|
+
schema_version: 1,
|
|
683
|
+
conversation_id: manifest.id,
|
|
684
|
+
role: "assistant",
|
|
685
|
+
status: "streaming",
|
|
686
|
+
created_at: now,
|
|
687
|
+
updated_at: now,
|
|
688
|
+
sender: { id: "agent_default", type: "agent", display_name: "Hermes", profile: "default" },
|
|
689
|
+
parts: [{ type: "text", text: "" }],
|
|
690
|
+
attachments: []
|
|
691
|
+
};
|
|
692
|
+
const run = {
|
|
693
|
+
id: `run_${randomUUID().replaceAll("-", "")}`,
|
|
694
|
+
conversation_id: manifest.id,
|
|
695
|
+
trigger_message_id: userMessage.id,
|
|
696
|
+
assistant_message_id: assistantMessage.id,
|
|
697
|
+
hermes_session_id: manifest.hermes_session_id,
|
|
698
|
+
status: "running",
|
|
699
|
+
started_at: now
|
|
700
|
+
};
|
|
701
|
+
snapshot.messages.push(userMessage, assistantMessage);
|
|
702
|
+
snapshot.runs.push(run);
|
|
703
|
+
await this.writeSnapshot(manifest.id, snapshot);
|
|
704
|
+
await this.appendEvent(manifest.id, {
|
|
705
|
+
type: "message.created",
|
|
706
|
+
message_id: userMessage.id,
|
|
707
|
+
payload: { message: userMessage }
|
|
708
|
+
});
|
|
709
|
+
await this.appendEvent(manifest.id, {
|
|
710
|
+
type: "message.created",
|
|
711
|
+
message_id: assistantMessage.id,
|
|
712
|
+
run_id: run.id,
|
|
713
|
+
payload: { message: assistantMessage }
|
|
714
|
+
});
|
|
715
|
+
const runEvent = await this.appendEvent(manifest.id, {
|
|
716
|
+
type: "run.started",
|
|
717
|
+
message_id: assistantMessage.id,
|
|
718
|
+
run_id: run.id,
|
|
719
|
+
payload: { run }
|
|
720
|
+
});
|
|
721
|
+
void this.startRunWorker(manifest.id, run.id, this.buildHermesInput(content, userAttachmentParts)).catch((error) => {
|
|
722
|
+
void this.failRun(manifest.id, run.id, error instanceof Error ? error.message : String(error));
|
|
723
|
+
});
|
|
724
|
+
return {
|
|
725
|
+
conversation_id: manifest.id,
|
|
726
|
+
user_message: { id: userMessage.id, status: userMessage.status },
|
|
727
|
+
assistant_message: { id: assistantMessage.id, status: assistantMessage.status },
|
|
728
|
+
run: { id: run.id, status: run.status },
|
|
729
|
+
last_event_seq: runEvent.seq
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
async deleteConversation(conversationId) {
|
|
733
|
+
const manifest = await this.readActiveManifest(conversationId);
|
|
734
|
+
const snapshot = await this.readSnapshot(conversationId).catch(() => emptySnapshot());
|
|
735
|
+
const referencedBlobIds = /* @__PURE__ */ new Set([
|
|
736
|
+
...collectBlobIds(snapshot),
|
|
737
|
+
...await this.listConversationBlobIds(conversationId)
|
|
738
|
+
]);
|
|
739
|
+
await deleteHermesSession(manifest.hermes_session_id);
|
|
740
|
+
const deletedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
741
|
+
const next = {
|
|
742
|
+
...manifest,
|
|
743
|
+
status: "deleted_soft",
|
|
744
|
+
updated_at: deletedAt,
|
|
745
|
+
deleted_at: deletedAt
|
|
746
|
+
};
|
|
747
|
+
await this.writeManifest(next);
|
|
748
|
+
const deleteEvent = await this.appendEvent(conversationId, {
|
|
749
|
+
type: "conversation.deleted",
|
|
750
|
+
payload: { deleted_at: deletedAt, hermes_session_id: manifest.hermes_session_id }
|
|
751
|
+
});
|
|
752
|
+
await this.writeSnapshot(conversationId, emptySnapshot());
|
|
753
|
+
await writeFile2(this.eventsPath(conversationId), `${JSON.stringify(deleteEvent)}
|
|
754
|
+
`, { mode: 384 });
|
|
755
|
+
await this.pruneConversationBlobReferences(conversationId, [...referencedBlobIds]);
|
|
756
|
+
return { conversation_id: conversationId, hermes_deleted: true, deleted_at: deletedAt };
|
|
757
|
+
}
|
|
758
|
+
async writeBlob(conversationId, input) {
|
|
759
|
+
await this.readActiveManifest(conversationId);
|
|
760
|
+
if (input.bytes.byteLength > MAX_UPLOADED_BLOB_BYTES) {
|
|
761
|
+
throw new LinkHttpError(413, "blob_too_large", "Blob is too large");
|
|
762
|
+
}
|
|
763
|
+
const id = `blob_${randomUUID().replaceAll("-", "")}`;
|
|
764
|
+
const filePath = this.blobPath(id);
|
|
765
|
+
await mkdir4(path5.dirname(filePath), { recursive: true, mode: 448 });
|
|
766
|
+
await writeFile2(filePath, input.bytes, { mode: 384 });
|
|
767
|
+
const manifestPath = `${filePath}.json`;
|
|
768
|
+
const blob = {
|
|
769
|
+
id,
|
|
770
|
+
size: input.bytes.byteLength,
|
|
771
|
+
mime: input.mime || "application/octet-stream",
|
|
772
|
+
filename: sanitizeFilename(input.filename, id),
|
|
773
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
774
|
+
conversation_ids: [conversationId]
|
|
775
|
+
};
|
|
776
|
+
await writeJsonFile(manifestPath, blob);
|
|
777
|
+
await this.appendEvent(conversationId, {
|
|
778
|
+
type: "blob.created",
|
|
779
|
+
payload: blob
|
|
780
|
+
});
|
|
781
|
+
return blob;
|
|
782
|
+
}
|
|
783
|
+
async readBlob(conversationId, blobId) {
|
|
784
|
+
await this.readActiveManifest(conversationId);
|
|
785
|
+
const filePath = this.blobPath(blobId);
|
|
786
|
+
const manifest = await this.readBlobManifest(conversationId, blobId);
|
|
787
|
+
const bytes = await readFile3(filePath).catch((error) => {
|
|
788
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
789
|
+
throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
|
|
790
|
+
}
|
|
791
|
+
throw error;
|
|
792
|
+
});
|
|
793
|
+
return {
|
|
794
|
+
bytes,
|
|
795
|
+
mime: manifest.mime || "application/octet-stream",
|
|
796
|
+
filename: manifest.filename || blobId,
|
|
797
|
+
size: manifest.size || bytes.byteLength
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
async resolveMessageAttachmentParts(conversationId, attachments) {
|
|
801
|
+
const parts = [];
|
|
802
|
+
const seen = /* @__PURE__ */ new Set();
|
|
803
|
+
for (const attachment of attachments) {
|
|
804
|
+
const blobId = attachment.blob_id ?? attachment.blobId;
|
|
805
|
+
if (!blobId || seen.has(blobId)) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
seen.add(blobId);
|
|
809
|
+
const manifest = await this.readBlobManifest(conversationId, blobId);
|
|
810
|
+
const mime = manifest.mime || "application/octet-stream";
|
|
811
|
+
parts.push({
|
|
812
|
+
type: mediaKindForMime(mime),
|
|
813
|
+
blob: blobId,
|
|
814
|
+
mime,
|
|
815
|
+
size: manifest.size,
|
|
816
|
+
filename: manifest.filename || blobId,
|
|
817
|
+
url: `/api/v1/conversations/${encodeURIComponent(conversationId)}/blobs/${encodeURIComponent(blobId)}`
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return parts;
|
|
821
|
+
}
|
|
822
|
+
buildHermesInput(content, parts) {
|
|
823
|
+
const attachmentLines = parts.filter((part) => part.blob).map((part) => {
|
|
824
|
+
const label = part.filename ?? part.blob;
|
|
825
|
+
const mime = part.mime ? `, ${part.mime}` : "";
|
|
826
|
+
const size = part.size ? `, ${part.size} bytes` : "";
|
|
827
|
+
return `- ${label}${mime}${size}: ${this.blobPath(part.blob)}`;
|
|
828
|
+
});
|
|
829
|
+
if (attachmentLines.length === 0) {
|
|
830
|
+
return content;
|
|
831
|
+
}
|
|
832
|
+
const prefix = content ? `${content}
|
|
833
|
+
|
|
834
|
+
` : "";
|
|
835
|
+
return `${prefix}Attachments available on this computer:
|
|
836
|
+
${attachmentLines.join("\n")}`;
|
|
837
|
+
}
|
|
838
|
+
async readBlobManifest(conversationId, blobId) {
|
|
839
|
+
assertValidConversationId(conversationId);
|
|
840
|
+
assertValidBlobId(blobId);
|
|
841
|
+
const manifest = await readJsonFile(
|
|
842
|
+
`${this.blobPath(blobId)}.json`
|
|
843
|
+
);
|
|
844
|
+
if (!manifest?.conversation_ids?.includes(conversationId)) {
|
|
845
|
+
throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
|
|
846
|
+
}
|
|
847
|
+
return manifest;
|
|
848
|
+
}
|
|
849
|
+
async writeBlobFromFile(conversationId, source) {
|
|
850
|
+
const sourcePath = resolveMediaSourcePath(source.path);
|
|
851
|
+
const fileStat = await stat(sourcePath).catch((error) => {
|
|
852
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
853
|
+
throw new LinkHttpError(404, "media_source_not_found", "Hermes output file was not found");
|
|
854
|
+
}
|
|
855
|
+
throw error;
|
|
856
|
+
});
|
|
857
|
+
if (!fileStat.isFile()) {
|
|
858
|
+
throw new LinkHttpError(400, "media_source_not_file", "Hermes output media source is not a file");
|
|
859
|
+
}
|
|
860
|
+
if (fileStat.size > MAX_IMPORTED_BLOB_BYTES) {
|
|
861
|
+
throw new LinkHttpError(413, "media_source_too_large", "Hermes output media source is too large");
|
|
862
|
+
}
|
|
863
|
+
return this.writeBlob(conversationId, {
|
|
864
|
+
bytes: await readFile3(sourcePath),
|
|
865
|
+
filename: path5.basename(sourcePath),
|
|
866
|
+
mime: source.mime ?? inferMimeType(sourcePath)
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
async startRunWorker(conversationId, runId, input) {
|
|
870
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
871
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
872
|
+
if (!run) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const runResponse = await createHermesRun({
|
|
876
|
+
input,
|
|
877
|
+
session_id: run.hermes_session_id,
|
|
878
|
+
conversation_history: buildConversationHistory(snapshot, run)
|
|
879
|
+
});
|
|
880
|
+
await this.updateRun(conversationId, runId, { hermes_run_id: runResponse.run_id });
|
|
881
|
+
const response = await streamHermesRunEvents(runResponse.run_id);
|
|
882
|
+
for await (const event of parseSseResponse(response)) {
|
|
883
|
+
if (!await this.isConversationActive(conversationId)) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const type = event.payloadType;
|
|
887
|
+
if (type === "run.completed") {
|
|
888
|
+
await this.importMediaReferencesForEvent(conversationId, runId, event);
|
|
889
|
+
await this.completeRun(conversationId, runId, event);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (type === "run.failed") {
|
|
893
|
+
await this.importMediaReferencesForEvent(conversationId, runId, event);
|
|
894
|
+
await this.failRun(conversationId, runId, readErrorMessage(event.payload) ?? "Hermes run failed", event);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
await this.persistHermesEvent(conversationId, runId, event);
|
|
898
|
+
}
|
|
899
|
+
await this.completeRun(conversationId, runId);
|
|
900
|
+
}
|
|
901
|
+
async persistHermesEvent(conversationId, runId, event) {
|
|
902
|
+
if (!await this.isConversationActive(conversationId)) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const type = event.payloadType;
|
|
906
|
+
if (type === "message.delta") {
|
|
907
|
+
const delta = readDelta(event.payload);
|
|
908
|
+
if (delta) {
|
|
909
|
+
await this.appendAssistantDelta(conversationId, runId, delta, event.payload);
|
|
910
|
+
}
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const messageId = await this.assistantMessageIdForRun(conversationId, runId);
|
|
914
|
+
await this.appendEvent(conversationId, {
|
|
915
|
+
type,
|
|
916
|
+
message_id: messageId,
|
|
917
|
+
run_id: runId,
|
|
918
|
+
payload: event.payload,
|
|
919
|
+
raw: { format: "hermes-run-event", payload: event.rawPayload }
|
|
920
|
+
});
|
|
921
|
+
if (messageId) {
|
|
922
|
+
await this.importMediaReferences(conversationId, runId, messageId, collectMediaReferences(event.payload));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async importMediaReferencesForEvent(conversationId, runId, event) {
|
|
926
|
+
const messageId = await this.assistantMessageIdForRun(conversationId, runId);
|
|
927
|
+
if (!messageId) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
await this.importMediaReferences(conversationId, runId, messageId, collectMediaReferences(event.payload));
|
|
931
|
+
}
|
|
932
|
+
async appendAssistantDelta(conversationId, runId, delta, rawPayload) {
|
|
933
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
934
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
935
|
+
if (!run) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const assistant = snapshot.messages.find((message) => message.id === run.assistant_message_id);
|
|
939
|
+
if (!assistant) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const textPart = assistant.parts.find((part) => part.type === "text");
|
|
943
|
+
if (textPart) {
|
|
944
|
+
textPart.text = `${textPart.text ?? ""}${delta}`;
|
|
945
|
+
} else {
|
|
946
|
+
assistant.parts.push({ type: "text", text: delta });
|
|
947
|
+
}
|
|
948
|
+
assistant.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
949
|
+
assistant.raw = { format: "hermes-run-event", payload: rawPayload };
|
|
950
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
951
|
+
await this.appendEvent(conversationId, {
|
|
952
|
+
type: "message.delta",
|
|
953
|
+
message_id: assistant.id,
|
|
954
|
+
run_id: runId,
|
|
955
|
+
payload: { delta },
|
|
956
|
+
raw: { format: "hermes-run-event", payload: rawPayload }
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
async completeRun(conversationId, runId, source) {
|
|
960
|
+
let snapshot = await this.readSnapshot(conversationId);
|
|
961
|
+
let run = snapshot.runs.find((item) => item.id === runId);
|
|
962
|
+
if (!run) {
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const initialRun = run;
|
|
966
|
+
let assistant = snapshot.messages.find((message) => message.id === initialRun.assistant_message_id);
|
|
967
|
+
if (assistant) {
|
|
968
|
+
await this.importMediaReferences(conversationId, runId, assistant.id, collectMediaTags(messageText(assistant)));
|
|
969
|
+
snapshot = await this.readSnapshot(conversationId);
|
|
970
|
+
const refreshedRun = snapshot.runs.find((item) => item.id === runId);
|
|
971
|
+
if (!refreshedRun) {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
run = refreshedRun;
|
|
975
|
+
assistant = snapshot.messages.find((message) => message.id === refreshedRun.assistant_message_id);
|
|
976
|
+
}
|
|
977
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
978
|
+
run.status = "completed";
|
|
979
|
+
run.completed_at = completedAt;
|
|
980
|
+
if (assistant) {
|
|
981
|
+
assistant.status = "completed";
|
|
982
|
+
assistant.updated_at = completedAt;
|
|
983
|
+
cleanMessageTextParts(assistant);
|
|
984
|
+
}
|
|
985
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
986
|
+
if (assistant) {
|
|
987
|
+
await this.appendEvent(conversationId, {
|
|
988
|
+
type: "message.completed",
|
|
989
|
+
message_id: assistant.id,
|
|
990
|
+
run_id: runId,
|
|
991
|
+
payload: { message: assistant }
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
await this.appendEvent(conversationId, {
|
|
995
|
+
type: "run.completed",
|
|
996
|
+
message_id: assistant?.id,
|
|
997
|
+
run_id: runId,
|
|
998
|
+
payload: { run, ...source ? { hermes: source.payload } : {} },
|
|
999
|
+
...source ? { raw: { format: "hermes-run-event", payload: source.rawPayload } } : {}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
async failRun(conversationId, runId, message, source) {
|
|
1003
|
+
const snapshot = await this.readSnapshot(conversationId).catch(() => null);
|
|
1004
|
+
if (!snapshot) {
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
1008
|
+
if (!run) {
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
run.status = "failed";
|
|
1012
|
+
run.completed_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1013
|
+
run.error_message = message;
|
|
1014
|
+
const assistant = snapshot.messages.find((item) => item.id === run.assistant_message_id);
|
|
1015
|
+
if (assistant) {
|
|
1016
|
+
assistant.status = "failed";
|
|
1017
|
+
assistant.updated_at = run.completed_at;
|
|
1018
|
+
const textPart = assistant.parts.find((part) => part.type === "text");
|
|
1019
|
+
if (textPart && !textPart.text) {
|
|
1020
|
+
textPart.text = message;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
1024
|
+
await this.appendEvent(conversationId, {
|
|
1025
|
+
type: "run.failed",
|
|
1026
|
+
message_id: assistant?.id,
|
|
1027
|
+
run_id: runId,
|
|
1028
|
+
payload: { error: { message }, run, ...source ? { hermes: source.payload } : {} },
|
|
1029
|
+
...source ? { raw: { format: "hermes-run-event", payload: source.rawPayload } } : {}
|
|
1030
|
+
});
|
|
1031
|
+
void this.logger.warn("conversation_run_failed", { conversation_id: conversationId, run_id: runId, error: message });
|
|
1032
|
+
}
|
|
1033
|
+
async updateRun(conversationId, runId, patch) {
|
|
1034
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
1035
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
1036
|
+
if (!run) {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
Object.assign(run, patch);
|
|
1040
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
1041
|
+
}
|
|
1042
|
+
async assistantMessageIdForRun(conversationId, runId) {
|
|
1043
|
+
const snapshot = await this.readSnapshot(conversationId).catch(() => null);
|
|
1044
|
+
return snapshot?.runs.find((item) => item.id === runId)?.assistant_message_id;
|
|
1045
|
+
}
|
|
1046
|
+
async importMediaReferences(conversationId, runId, messageId, references) {
|
|
1047
|
+
if (references.length === 0) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
1051
|
+
const assistant = snapshot.messages.find((message) => message.id === messageId);
|
|
1052
|
+
if (!assistant) {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const importedSourceKeys = readImportedMediaSourceKeys(assistant);
|
|
1056
|
+
const importedParts = [];
|
|
1057
|
+
for (const reference of references.slice(0, 10)) {
|
|
1058
|
+
try {
|
|
1059
|
+
const sourceKey = mediaSourceKey(reference.path);
|
|
1060
|
+
if (importedSourceKeys.has(sourceKey)) {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
const blob = await this.writeBlobFromFile(conversationId, reference);
|
|
1064
|
+
const part = {
|
|
1065
|
+
type: reference.kind ?? mediaKindForMime(blob.mime),
|
|
1066
|
+
blob: blob.id,
|
|
1067
|
+
mime: blob.mime,
|
|
1068
|
+
size: blob.size,
|
|
1069
|
+
filename: blob.filename,
|
|
1070
|
+
url: `/api/v1/conversations/${encodeURIComponent(conversationId)}/blobs/${encodeURIComponent(blob.id)}`
|
|
1071
|
+
};
|
|
1072
|
+
assistant.parts.push(part);
|
|
1073
|
+
assistant.attachments.push({
|
|
1074
|
+
blob_id: blob.id,
|
|
1075
|
+
mime: blob.mime,
|
|
1076
|
+
size: blob.size,
|
|
1077
|
+
filename: blob.filename,
|
|
1078
|
+
source: "hermes_output"
|
|
1079
|
+
});
|
|
1080
|
+
importedSourceKeys.add(sourceKey);
|
|
1081
|
+
importedParts.push(part);
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
void this.logger.warn("conversation_media_import_failed", {
|
|
1084
|
+
conversation_id: conversationId,
|
|
1085
|
+
run_id: runId,
|
|
1086
|
+
message_id: messageId,
|
|
1087
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (importedParts.length === 0) {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
assistant.hermes = {
|
|
1095
|
+
...toRecord2(assistant.hermes),
|
|
1096
|
+
imported_media_source_keys: [...importedSourceKeys]
|
|
1097
|
+
};
|
|
1098
|
+
assistant.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1099
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
1100
|
+
await this.appendEvent(conversationId, {
|
|
1101
|
+
type: "message.parts.created",
|
|
1102
|
+
message_id: messageId,
|
|
1103
|
+
run_id: runId,
|
|
1104
|
+
payload: { parts: importedParts }
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
async appendEvent(conversationId, input) {
|
|
1108
|
+
const manifest = await this.readManifest(conversationId);
|
|
1109
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1110
|
+
const event = {
|
|
1111
|
+
...input,
|
|
1112
|
+
seq: manifest.last_event_seq + 1,
|
|
1113
|
+
conversation_id: conversationId,
|
|
1114
|
+
created_at: now
|
|
1115
|
+
};
|
|
1116
|
+
await mkdir4(this.conversationDir(conversationId), { recursive: true, mode: 448 });
|
|
1117
|
+
await appendFile(this.eventsPath(conversationId), `${JSON.stringify(event)}
|
|
1118
|
+
`, { mode: 384 });
|
|
1119
|
+
await this.writeManifest({
|
|
1120
|
+
...manifest,
|
|
1121
|
+
last_event_seq: event.seq,
|
|
1122
|
+
updated_at: now
|
|
1123
|
+
});
|
|
1124
|
+
this.emitter.emit(this.liveEventName(conversationId), event);
|
|
1125
|
+
return event;
|
|
1126
|
+
}
|
|
1127
|
+
async readActiveManifest(conversationId) {
|
|
1128
|
+
const manifest = await this.readManifest(conversationId);
|
|
1129
|
+
if (manifest.status !== "active") {
|
|
1130
|
+
throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
|
|
1131
|
+
}
|
|
1132
|
+
return manifest;
|
|
1133
|
+
}
|
|
1134
|
+
async isConversationActive(conversationId) {
|
|
1135
|
+
const manifest = await this.readManifest(conversationId).catch(() => null);
|
|
1136
|
+
return manifest?.status === "active";
|
|
1137
|
+
}
|
|
1138
|
+
async readManifest(conversationId) {
|
|
1139
|
+
const manifest = await readJsonFile(this.manifestPath(conversationId));
|
|
1140
|
+
if (!manifest) {
|
|
1141
|
+
throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
|
|
1142
|
+
}
|
|
1143
|
+
return manifest;
|
|
1144
|
+
}
|
|
1145
|
+
writeManifest(manifest) {
|
|
1146
|
+
return writeJsonFile(this.manifestPath(manifest.id), manifest);
|
|
1147
|
+
}
|
|
1148
|
+
async readSnapshot(conversationId) {
|
|
1149
|
+
return await readJsonFile(this.snapshotPath(conversationId)) ?? emptySnapshot();
|
|
1150
|
+
}
|
|
1151
|
+
writeSnapshot(conversationId, snapshot) {
|
|
1152
|
+
return writeJsonFile(this.snapshotPath(conversationId), snapshot);
|
|
1153
|
+
}
|
|
1154
|
+
conversationDir(conversationId) {
|
|
1155
|
+
assertValidConversationId(conversationId);
|
|
1156
|
+
return path5.join(this.paths.conversationsDir, conversationId);
|
|
1157
|
+
}
|
|
1158
|
+
manifestPath(conversationId) {
|
|
1159
|
+
return path5.join(this.conversationDir(conversationId), "manifest.json");
|
|
1160
|
+
}
|
|
1161
|
+
snapshotPath(conversationId) {
|
|
1162
|
+
return path5.join(this.conversationDir(conversationId), "snapshot.json");
|
|
1163
|
+
}
|
|
1164
|
+
eventsPath(conversationId) {
|
|
1165
|
+
return path5.join(this.conversationDir(conversationId), "events.ndjson");
|
|
1166
|
+
}
|
|
1167
|
+
blobPath(blobId) {
|
|
1168
|
+
assertValidBlobId(blobId);
|
|
1169
|
+
return path5.join(this.paths.blobsDir, `${blobId}.bin`);
|
|
1170
|
+
}
|
|
1171
|
+
liveEventName(conversationId) {
|
|
1172
|
+
return `conversation:${conversationId}`;
|
|
1173
|
+
}
|
|
1174
|
+
async pruneConversationBlobReferences(conversationId, blobIds) {
|
|
1175
|
+
for (const blobId of blobIds) {
|
|
1176
|
+
try {
|
|
1177
|
+
const manifestPath = `${this.blobPath(blobId)}.json`;
|
|
1178
|
+
const manifest = await readJsonFile(manifestPath);
|
|
1179
|
+
const nextConversationIds = (manifest?.conversation_ids ?? []).filter((id) => id !== conversationId);
|
|
1180
|
+
if (nextConversationIds.length > 0) {
|
|
1181
|
+
await writeJsonFile(manifestPath, { ...manifest, conversation_ids: nextConversationIds });
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
await rm2(this.blobPath(blobId), { force: true });
|
|
1185
|
+
await rm2(manifestPath, { force: true });
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
void this.logger.warn("conversation_blob_gc_failed", {
|
|
1188
|
+
conversation_id: conversationId,
|
|
1189
|
+
blob_id: blobId,
|
|
1190
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
async listConversationBlobIds(conversationId) {
|
|
1196
|
+
await mkdir4(this.paths.blobsDir, { recursive: true, mode: 448 });
|
|
1197
|
+
const entries = await readdir(this.paths.blobsDir, { withFileTypes: true }).catch((error) => {
|
|
1198
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
1199
|
+
return [];
|
|
1200
|
+
}
|
|
1201
|
+
throw error;
|
|
1202
|
+
});
|
|
1203
|
+
const blobIds = [];
|
|
1204
|
+
for (const entry of entries) {
|
|
1205
|
+
if (!entry.isFile() || !entry.name.endsWith(".bin.json")) {
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
const blobId = entry.name.slice(0, -".bin.json".length);
|
|
1209
|
+
if (!BLOB_ID_PATTERN.test(blobId)) {
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
const manifest = await readJsonFile(
|
|
1213
|
+
path5.join(this.paths.blobsDir, entry.name)
|
|
1214
|
+
).catch(() => null);
|
|
1215
|
+
if (manifest?.conversation_ids?.includes(conversationId)) {
|
|
1216
|
+
blobIds.push(blobId);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return blobIds;
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
async function* parseSseResponse(response) {
|
|
1223
|
+
if (!response.body) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const decoder = new TextDecoder();
|
|
1227
|
+
let buffer = "";
|
|
1228
|
+
for await (const chunk of response.body) {
|
|
1229
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1230
|
+
let separatorIndex = buffer.indexOf("\n\n");
|
|
1231
|
+
while (separatorIndex >= 0) {
|
|
1232
|
+
const block = buffer.slice(0, separatorIndex);
|
|
1233
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
1234
|
+
const parsed = parseSseBlock(block);
|
|
1235
|
+
if (parsed) {
|
|
1236
|
+
yield parsed;
|
|
1237
|
+
}
|
|
1238
|
+
separatorIndex = buffer.indexOf("\n\n");
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const trailing = parseSseBlock(buffer);
|
|
1242
|
+
if (trailing) {
|
|
1243
|
+
yield trailing;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
function parseSseBlock(block) {
|
|
1247
|
+
const lines = block.split("\n");
|
|
1248
|
+
let eventName = "";
|
|
1249
|
+
const data = [];
|
|
1250
|
+
for (const rawLine of lines) {
|
|
1251
|
+
const line = rawLine.trimEnd();
|
|
1252
|
+
if (line.startsWith("event:")) {
|
|
1253
|
+
eventName = line.slice(6).trim();
|
|
1254
|
+
} else if (line.startsWith("data:")) {
|
|
1255
|
+
data.push(line.slice(5).trimStart());
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (!eventName && data.length === 0) {
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
const raw = data.join("\n");
|
|
1262
|
+
const decoded = decodeJson(raw);
|
|
1263
|
+
const payload = toRecord2(decoded);
|
|
1264
|
+
const payloadType = (readString2(payload, "type") ?? readString2(payload, "event") ?? eventName) || "message";
|
|
1265
|
+
return { eventName, payloadType, payload, rawPayload: decoded ?? raw };
|
|
1266
|
+
}
|
|
1267
|
+
function decodeJson(value) {
|
|
1268
|
+
if (!value.trim()) {
|
|
1269
|
+
return {};
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
return JSON.parse(value);
|
|
1273
|
+
} catch {
|
|
1274
|
+
return { type: "message.delta", delta: value };
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
function emptySnapshot() {
|
|
1278
|
+
return { schema_version: 1, messages: [], runs: [] };
|
|
1279
|
+
}
|
|
1280
|
+
function toSummary(manifest, snapshot) {
|
|
1281
|
+
const lastMessage = [...snapshot.messages].reverse().find((message) => message.parts.some((part) => part.type === "text" && part.text));
|
|
1282
|
+
return {
|
|
1283
|
+
id: manifest.id,
|
|
1284
|
+
title: manifest.title,
|
|
1285
|
+
created_at: manifest.created_at,
|
|
1286
|
+
updated_at: manifest.updated_at,
|
|
1287
|
+
last_event_seq: manifest.last_event_seq,
|
|
1288
|
+
last_message: lastMessage ? {
|
|
1289
|
+
id: lastMessage.id,
|
|
1290
|
+
role: lastMessage.role,
|
|
1291
|
+
content_preview: previewText(lastMessage)
|
|
1292
|
+
} : null
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
function previewText(message) {
|
|
1296
|
+
return messageText(message).slice(0, 160);
|
|
1297
|
+
}
|
|
1298
|
+
function messageText(message) {
|
|
1299
|
+
return message.parts.filter((part) => part.type === "text" && part.text).map((part) => part.text).join("").trim();
|
|
1300
|
+
}
|
|
1301
|
+
function buildConversationHistory(snapshot, run) {
|
|
1302
|
+
const triggerIndex = snapshot.messages.findIndex((message) => message.id === run.trigger_message_id);
|
|
1303
|
+
const previousMessages = triggerIndex >= 0 ? snapshot.messages.slice(0, triggerIndex) : snapshot.messages;
|
|
1304
|
+
return previousMessages.filter((message) => (message.role === "user" || message.role === "assistant") && message.status === "completed").map((message) => ({
|
|
1305
|
+
role: message.role,
|
|
1306
|
+
content: messageText(message)
|
|
1307
|
+
})).filter((message) => message.content);
|
|
1308
|
+
}
|
|
1309
|
+
function readDelta(payload) {
|
|
1310
|
+
return readString2(payload, "delta") ?? readString2(payload, "text") ?? readString2(payload, "content");
|
|
1311
|
+
}
|
|
1312
|
+
function readErrorMessage(payload) {
|
|
1313
|
+
const error = toRecord2(payload.error);
|
|
1314
|
+
return readString2(error, "message") ?? readString2(payload, "message");
|
|
1315
|
+
}
|
|
1316
|
+
function collectMediaReferences(payload) {
|
|
1317
|
+
const references = [];
|
|
1318
|
+
collectStructuredMediaReferences(payload, references);
|
|
1319
|
+
for (const key of ["output", "content", "message", "text"]) {
|
|
1320
|
+
const value = payload[key];
|
|
1321
|
+
if (typeof value === "string") {
|
|
1322
|
+
references.push(...collectMediaTags(value));
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const unique2 = /* @__PURE__ */ new Set();
|
|
1326
|
+
return references.filter((reference) => {
|
|
1327
|
+
const key = `${reference.path}|${reference.mime ?? ""}|${reference.kind ?? ""}`;
|
|
1328
|
+
if (unique2.has(key)) {
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
unique2.add(key);
|
|
1332
|
+
return true;
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
function collectStructuredMediaReferences(value, references, depth = 0) {
|
|
1336
|
+
if (depth > 4 || value === null || typeof value !== "object") {
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (Array.isArray(value)) {
|
|
1340
|
+
for (const item of value) {
|
|
1341
|
+
collectStructuredMediaReferences(item, references, depth + 1);
|
|
1342
|
+
}
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
const record = value;
|
|
1346
|
+
for (const [key, item] of Object.entries(record)) {
|
|
1347
|
+
if (typeof item === "string" && isExplicitMediaPathKey(key)) {
|
|
1348
|
+
references.push({
|
|
1349
|
+
path: item,
|
|
1350
|
+
kind: mediaKindForMime(readString2(record, "mime") ?? readString2(record, "mime_type") ?? inferMimeType(item)),
|
|
1351
|
+
mime: readString2(record, "mime") ?? readString2(record, "mime_type") ?? void 0
|
|
1352
|
+
});
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
if (Array.isArray(item) || item !== null && typeof item === "object") {
|
|
1356
|
+
collectStructuredMediaReferences(item, references, depth + 1);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
function collectMediaTags(text) {
|
|
1361
|
+
const references = [];
|
|
1362
|
+
for (const match of text.matchAll(MEDIA_TAG_PATTERN)) {
|
|
1363
|
+
const rawPath = match[1]?.trim();
|
|
1364
|
+
const mediaPath = rawPath?.replace(/^["'`]|["'`]$/gu, "").replace(/["'`,.;:)}\]]+$/gu, "");
|
|
1365
|
+
if (mediaPath) {
|
|
1366
|
+
references.push({
|
|
1367
|
+
path: mediaPath,
|
|
1368
|
+
kind: mediaKindForMime(inferMimeType(mediaPath))
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
return references;
|
|
1373
|
+
}
|
|
1374
|
+
function cleanMessageTextParts(message) {
|
|
1375
|
+
for (const part of message.parts) {
|
|
1376
|
+
if (part.type === "text" && part.text) {
|
|
1377
|
+
part.text = cleanHermesDisplayText(part.text);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
function cleanHermesDisplayText(text) {
|
|
1382
|
+
if (!text.includes("MEDIA:") && !text.includes("[[audio_as_voice]]")) {
|
|
1383
|
+
return text;
|
|
1384
|
+
}
|
|
1385
|
+
return text.replaceAll("[[audio_as_voice]]", "").replace(MEDIA_TAG_PATTERN, "").replace(/\n{3,}/gu, "\n\n").trimEnd();
|
|
1386
|
+
}
|
|
1387
|
+
function isExplicitMediaPathKey(key) {
|
|
1388
|
+
return [
|
|
1389
|
+
"file_path",
|
|
1390
|
+
"screenshot_path",
|
|
1391
|
+
"image_path",
|
|
1392
|
+
"audio_path",
|
|
1393
|
+
"video_path",
|
|
1394
|
+
"media_path",
|
|
1395
|
+
"artifact_path"
|
|
1396
|
+
].includes(key);
|
|
1397
|
+
}
|
|
1398
|
+
function inferMimeType(filePath) {
|
|
1399
|
+
const extension = path5.extname(filePath).toLowerCase();
|
|
1400
|
+
return {
|
|
1401
|
+
".png": "image/png",
|
|
1402
|
+
".jpg": "image/jpeg",
|
|
1403
|
+
".jpeg": "image/jpeg",
|
|
1404
|
+
".gif": "image/gif",
|
|
1405
|
+
".webp": "image/webp",
|
|
1406
|
+
".heic": "image/heic",
|
|
1407
|
+
".pdf": "application/pdf",
|
|
1408
|
+
".txt": "text/plain",
|
|
1409
|
+
".md": "text/markdown",
|
|
1410
|
+
".json": "application/json",
|
|
1411
|
+
".csv": "text/csv",
|
|
1412
|
+
".mp3": "audio/mpeg",
|
|
1413
|
+
".wav": "audio/wav",
|
|
1414
|
+
".m4a": "audio/mp4",
|
|
1415
|
+
".ogg": "audio/ogg",
|
|
1416
|
+
".opus": "audio/ogg",
|
|
1417
|
+
".mp4": "video/mp4",
|
|
1418
|
+
".mov": "video/quicktime",
|
|
1419
|
+
".webm": "video/webm"
|
|
1420
|
+
}[extension] ?? "application/octet-stream";
|
|
1421
|
+
}
|
|
1422
|
+
function mediaKindForMime(mime) {
|
|
1423
|
+
if (mime.startsWith("image/")) {
|
|
1424
|
+
return "image";
|
|
1425
|
+
}
|
|
1426
|
+
if (mime.startsWith("audio/")) {
|
|
1427
|
+
return "audio";
|
|
1428
|
+
}
|
|
1429
|
+
return "file";
|
|
1430
|
+
}
|
|
1431
|
+
function collectBlobIds(snapshot) {
|
|
1432
|
+
const blobIds = /* @__PURE__ */ new Set();
|
|
1433
|
+
for (const message of snapshot.messages) {
|
|
1434
|
+
for (const part of message.parts) {
|
|
1435
|
+
if (part.blob) {
|
|
1436
|
+
blobIds.add(part.blob);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
for (const attachment of message.attachments) {
|
|
1440
|
+
const record = toRecord2(attachment);
|
|
1441
|
+
const blobId = readString2(record, "blob_id") ?? readString2(record, "blobId");
|
|
1442
|
+
if (blobId) {
|
|
1443
|
+
blobIds.add(blobId);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return [...blobIds];
|
|
1448
|
+
}
|
|
1449
|
+
function readImportedMediaSourceKeys(message) {
|
|
1450
|
+
const hermes = toRecord2(message.hermes);
|
|
1451
|
+
const keys = hermes.imported_media_source_keys;
|
|
1452
|
+
if (!Array.isArray(keys)) {
|
|
1453
|
+
return /* @__PURE__ */ new Set();
|
|
1454
|
+
}
|
|
1455
|
+
return new Set(keys.filter((key) => typeof key === "string" && key.length > 0));
|
|
1456
|
+
}
|
|
1457
|
+
function mediaSourceKey(sourcePath) {
|
|
1458
|
+
return createHash("sha256").update(resolveMediaSourcePath(sourcePath)).digest("hex").slice(0, 32);
|
|
1459
|
+
}
|
|
1460
|
+
function sanitizeFilename(value, fallback) {
|
|
1461
|
+
const base = path5.basename((value ?? "").replace(/[\r\n\t]/gu, " ").trim());
|
|
1462
|
+
const safe = base.replace(/[/:\\]/gu, "_").slice(0, 200).trim();
|
|
1463
|
+
return safe || fallback;
|
|
1464
|
+
}
|
|
1465
|
+
function resolveMediaSourcePath(sourcePath) {
|
|
1466
|
+
const trimmed = sourcePath.trim();
|
|
1467
|
+
const expanded = trimmed.startsWith("~/") ? path5.join(process.env.HOME ?? "", trimmed.slice(2)) : trimmed;
|
|
1468
|
+
const resolved = path5.resolve(expanded);
|
|
1469
|
+
if (!path5.isAbsolute(expanded)) {
|
|
1470
|
+
throw new LinkHttpError(400, "media_source_path_not_absolute", "Hermes output media source must be an absolute path");
|
|
1471
|
+
}
|
|
1472
|
+
return resolved;
|
|
1473
|
+
}
|
|
1474
|
+
function assertValidConversationId(conversationId) {
|
|
1475
|
+
if (!CONVERSATION_ID_PATTERN.test(conversationId)) {
|
|
1476
|
+
throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
function assertValidBlobId(blobId) {
|
|
1480
|
+
if (!BLOB_ID_PATTERN.test(blobId)) {
|
|
1481
|
+
throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
function readString2(payload, key) {
|
|
1485
|
+
const value = payload[key];
|
|
1486
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
1487
|
+
}
|
|
1488
|
+
function toRecord2(value) {
|
|
1489
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1490
|
+
}
|
|
1491
|
+
function isNodeError3(error, code) {
|
|
1492
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/hermes/profiles.ts
|
|
1496
|
+
import { mkdir as mkdir5, readdir as readdir2, rename as rename2, rm as rm3, stat as stat2 } from "fs/promises";
|
|
1497
|
+
import os3 from "os";
|
|
1498
|
+
import path6 from "path";
|
|
1499
|
+
var DEFAULT_PROFILE = "default";
|
|
1500
|
+
var PROFILE_NAME_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
1501
|
+
async function listHermesProfiles(paths = resolveRuntimePaths()) {
|
|
1502
|
+
const activeProfile = await getActiveProfile(paths);
|
|
1503
|
+
const profiles = /* @__PURE__ */ new Map();
|
|
1504
|
+
profiles.set(DEFAULT_PROFILE, profileInfo(DEFAULT_PROFILE, activeProfile));
|
|
1505
|
+
const profilesDir = path6.join(os3.homedir(), ".hermes", "profiles");
|
|
1506
|
+
const entries = await readdir2(profilesDir, { withFileTypes: true }).catch((error) => {
|
|
1507
|
+
if (isNodeError4(error, "ENOENT")) {
|
|
1508
|
+
return [];
|
|
1509
|
+
}
|
|
1510
|
+
throw error;
|
|
1511
|
+
});
|
|
1512
|
+
for (const entry of entries) {
|
|
1513
|
+
if (entry.isDirectory() && PROFILE_NAME_PATTERN.test(entry.name)) {
|
|
1514
|
+
profiles.set(entry.name, profileInfo(entry.name, activeProfile));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
return [...profiles.values()].sort((left, right) => {
|
|
1518
|
+
if (left.name === DEFAULT_PROFILE) {
|
|
1519
|
+
return -1;
|
|
1520
|
+
}
|
|
1521
|
+
if (right.name === DEFAULT_PROFILE) {
|
|
1522
|
+
return 1;
|
|
1523
|
+
}
|
|
1524
|
+
return left.name.localeCompare(right.name);
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
async function getHermesProfileStatus(name, paths = resolveRuntimePaths()) {
|
|
1528
|
+
assertProfileName(name);
|
|
1529
|
+
const profile = profileInfo(name, await getActiveProfile(paths));
|
|
1530
|
+
const exists = await stat2(profile.path).then((value) => value.isDirectory()).catch((error) => {
|
|
1531
|
+
if (isNodeError4(error, "ENOENT")) {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
throw error;
|
|
1535
|
+
});
|
|
1536
|
+
const config = await readHermesApiServerConfig(name, profile.configPath);
|
|
1537
|
+
return {
|
|
1538
|
+
...profile,
|
|
1539
|
+
exists,
|
|
1540
|
+
apiKeyConfigured: Boolean(config.key)
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
async function createHermesProfile(name) {
|
|
1544
|
+
assertProfileName(name);
|
|
1545
|
+
if (name === DEFAULT_PROFILE) {
|
|
1546
|
+
throw new Error("default profile already exists");
|
|
1547
|
+
}
|
|
1548
|
+
const profile = profileInfo(name, DEFAULT_PROFILE);
|
|
1549
|
+
if (await pathExists(profile.path)) {
|
|
1550
|
+
throw new Error("profile already exists");
|
|
1551
|
+
}
|
|
1552
|
+
await mkdir5(profile.path, { recursive: true, mode: 448 });
|
|
1553
|
+
await ensureHermesApiServerKey(name, profile.configPath);
|
|
1554
|
+
return profile;
|
|
1555
|
+
}
|
|
1556
|
+
async function useHermesProfile(name, paths = resolveRuntimePaths()) {
|
|
1557
|
+
assertProfileName(name);
|
|
1558
|
+
const profile = profileInfo(name, name);
|
|
1559
|
+
await mkdir5(profile.path, { recursive: true, mode: 448 });
|
|
1560
|
+
const current = await readJsonFile(paths.stateFile) ?? {};
|
|
1561
|
+
await writeJsonFile(paths.stateFile, { ...current, activeProfile: name });
|
|
1562
|
+
return profile;
|
|
1563
|
+
}
|
|
1564
|
+
async function renameHermesProfile(oldName, newName) {
|
|
1565
|
+
assertMutableProfile(oldName);
|
|
1566
|
+
assertMutableProfile(newName);
|
|
1567
|
+
const oldProfile = profileInfo(oldName, DEFAULT_PROFILE);
|
|
1568
|
+
const newProfile = profileInfo(newName, DEFAULT_PROFILE);
|
|
1569
|
+
await rename2(oldProfile.path, newProfile.path);
|
|
1570
|
+
return newProfile;
|
|
1571
|
+
}
|
|
1572
|
+
async function deleteHermesProfile(name) {
|
|
1573
|
+
assertMutableProfile(name);
|
|
1574
|
+
await rm3(resolveHermesProfileDir(name), { recursive: true, force: true });
|
|
1575
|
+
}
|
|
1576
|
+
async function getActiveProfile(paths) {
|
|
1577
|
+
const state = await readJsonFile(paths.stateFile);
|
|
1578
|
+
return typeof state?.activeProfile === "string" && PROFILE_NAME_PATTERN.test(state.activeProfile) ? state.activeProfile : DEFAULT_PROFILE;
|
|
1579
|
+
}
|
|
1580
|
+
function profileInfo(name, activeProfile) {
|
|
1581
|
+
return {
|
|
1582
|
+
name,
|
|
1583
|
+
active: name === activeProfile,
|
|
1584
|
+
path: resolveHermesProfileDir(name),
|
|
1585
|
+
configPath: resolveHermesConfigPath(name)
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
function assertMutableProfile(name) {
|
|
1589
|
+
assertProfileName(name);
|
|
1590
|
+
if (name === DEFAULT_PROFILE) {
|
|
1591
|
+
throw new Error("default profile cannot be renamed or deleted");
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
function assertProfileName(name) {
|
|
1595
|
+
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
1596
|
+
throw new Error("invalid profile name");
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
async function pathExists(targetPath) {
|
|
1600
|
+
return await stat2(targetPath).then(() => true).catch((error) => {
|
|
1601
|
+
if (isNodeError4(error, "ENOENT")) {
|
|
1602
|
+
return false;
|
|
1603
|
+
}
|
|
1604
|
+
throw error;
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
function isNodeError4(error, code) {
|
|
1608
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// src/identity/identity.ts
|
|
1612
|
+
import { generateKeyPairSync, randomUUID as randomUUID2, sign } from "crypto";
|
|
1613
|
+
import { mkdir as mkdir6, chmod } from "fs/promises";
|
|
1614
|
+
import { z } from "zod";
|
|
1615
|
+
var linkIdentitySchema = z.object({
|
|
1616
|
+
install_id: z.string().min(1),
|
|
1617
|
+
link_id: z.string().min(1).nullable().optional(),
|
|
1618
|
+
public_key_pem: z.string().min(1),
|
|
1619
|
+
private_key_pem: z.string().min(1),
|
|
1620
|
+
created_at: z.string().min(1),
|
|
1621
|
+
updated_at: z.string().min(1)
|
|
1622
|
+
});
|
|
1623
|
+
async function loadIdentity(paths = resolveRuntimePaths()) {
|
|
1624
|
+
const value = await readJsonFile(paths.identityFile);
|
|
1625
|
+
if (value === null) {
|
|
1626
|
+
return null;
|
|
1627
|
+
}
|
|
1628
|
+
return linkIdentitySchema.parse(value);
|
|
1629
|
+
}
|
|
1630
|
+
async function ensureIdentity(paths = resolveRuntimePaths()) {
|
|
1631
|
+
const existing = await loadIdentity(paths);
|
|
1632
|
+
if (existing) {
|
|
1633
|
+
return existing;
|
|
1634
|
+
}
|
|
1635
|
+
await mkdir6(paths.homeDir, { recursive: true, mode: 448 });
|
|
1636
|
+
await chmod(paths.homeDir, 448).catch(() => void 0);
|
|
1637
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
1638
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1639
|
+
const identity = {
|
|
1640
|
+
install_id: `install_${randomUUID2().replaceAll("-", "")}`,
|
|
1641
|
+
link_id: null,
|
|
1642
|
+
public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
1643
|
+
private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
1644
|
+
created_at: now,
|
|
1645
|
+
updated_at: now
|
|
1646
|
+
};
|
|
1647
|
+
await writeJsonFile(paths.identityFile, identity);
|
|
1648
|
+
return identity;
|
|
1649
|
+
}
|
|
1650
|
+
async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
|
|
1651
|
+
const identity = await ensureIdentity(paths);
|
|
1652
|
+
const next = {
|
|
1653
|
+
...identity,
|
|
1654
|
+
link_id: linkId,
|
|
1655
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1656
|
+
};
|
|
1657
|
+
await writeJsonFile(paths.identityFile, next);
|
|
1658
|
+
return next;
|
|
1659
|
+
}
|
|
1660
|
+
function signRelayNonce(identity, nonce) {
|
|
1661
|
+
const signature = sign(null, Buffer.from(nonce, "utf8"), identity.private_key_pem);
|
|
1662
|
+
return signature.toString("base64url");
|
|
1663
|
+
}
|
|
1664
|
+
function getIdentityStatus(identity) {
|
|
1665
|
+
return {
|
|
1666
|
+
installId: identity.install_id,
|
|
1667
|
+
linkId: identity.link_id ?? null,
|
|
1668
|
+
hasPrivateKey: identity.private_key_pem.trim().length > 0,
|
|
1669
|
+
publicKeyPem: identity.public_key_pem
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// src/pairing/pairing.ts
|
|
1674
|
+
import path7 from "path";
|
|
1675
|
+
import { rm as rm4 } from "fs/promises";
|
|
1676
|
+
|
|
1677
|
+
// src/security/devices.ts
|
|
1678
|
+
import { randomBytes as randomBytes2, randomUUID as randomUUID3, timingSafeEqual, createHash as createHash2 } from "crypto";
|
|
1679
|
+
var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
|
|
1680
|
+
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
|
|
1681
|
+
async function createDeviceSession(input, paths = resolveRuntimePaths()) {
|
|
1682
|
+
const store = await readCredentialStore(paths);
|
|
1683
|
+
const now = /* @__PURE__ */ new Date();
|
|
1684
|
+
const accessToken = randomToken("hpat_");
|
|
1685
|
+
const refreshToken = randomToken("hprt_");
|
|
1686
|
+
const device = {
|
|
1687
|
+
id: `dev_${randomUUID3().replaceAll("-", "")}`,
|
|
1688
|
+
label: input.label,
|
|
1689
|
+
platform: input.platform,
|
|
1690
|
+
scope: "admin",
|
|
1691
|
+
access_token_hash: sha256(accessToken),
|
|
1692
|
+
access_expires_at: new Date(now.getTime() + ACCESS_TOKEN_TTL_MS).toISOString(),
|
|
1693
|
+
refresh_token_hash: sha256(refreshToken),
|
|
1694
|
+
refresh_expires_at: new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString(),
|
|
1695
|
+
created_at: now.toISOString(),
|
|
1696
|
+
updated_at: now.toISOString(),
|
|
1697
|
+
revoked_at: null
|
|
1698
|
+
};
|
|
1699
|
+
store.devices.push(device);
|
|
1700
|
+
await writeCredentialStore(paths, store);
|
|
1701
|
+
return formatDeviceSession(device, accessToken, refreshToken);
|
|
1702
|
+
}
|
|
1703
|
+
async function authenticateDeviceAccessToken(token, paths = resolveRuntimePaths()) {
|
|
1704
|
+
const tokenHash = sha256(token);
|
|
1705
|
+
const store = await readCredentialStore(paths);
|
|
1706
|
+
const device = store.devices.find((item) => safeEqual(item.access_token_hash, tokenHash));
|
|
1707
|
+
if (!device || device.revoked_at || Date.parse(device.access_expires_at) <= Date.now()) {
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
return device;
|
|
1711
|
+
}
|
|
1712
|
+
async function refreshDeviceSession(refreshToken, paths = resolveRuntimePaths()) {
|
|
1713
|
+
const tokenHash = sha256(refreshToken);
|
|
1714
|
+
const store = await readCredentialStore(paths);
|
|
1715
|
+
const device = store.devices.find((item) => safeEqual(item.refresh_token_hash, tokenHash));
|
|
1716
|
+
if (!device || device.revoked_at || Date.parse(device.refresh_expires_at) <= Date.now()) {
|
|
1717
|
+
throw new LinkHttpError(401, "refresh_token_invalid", "Refresh token is invalid or expired");
|
|
1718
|
+
}
|
|
1719
|
+
const now = /* @__PURE__ */ new Date();
|
|
1720
|
+
const nextAccessToken = randomToken("hpat_");
|
|
1721
|
+
const nextRefreshToken = randomToken("hprt_");
|
|
1722
|
+
device.access_token_hash = sha256(nextAccessToken);
|
|
1723
|
+
device.access_expires_at = new Date(now.getTime() + ACCESS_TOKEN_TTL_MS).toISOString();
|
|
1724
|
+
device.refresh_token_hash = sha256(nextRefreshToken);
|
|
1725
|
+
device.refresh_expires_at = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS).toISOString();
|
|
1726
|
+
device.updated_at = now.toISOString();
|
|
1727
|
+
await writeCredentialStore(paths, store);
|
|
1728
|
+
return formatDeviceSession(device, nextAccessToken, nextRefreshToken);
|
|
1729
|
+
}
|
|
1730
|
+
async function revokeDeviceRefreshToken(refreshToken, paths = resolveRuntimePaths()) {
|
|
1731
|
+
const tokenHash = sha256(refreshToken);
|
|
1732
|
+
const store = await readCredentialStore(paths);
|
|
1733
|
+
const device = store.devices.find((item) => safeEqual(item.refresh_token_hash, tokenHash));
|
|
1734
|
+
if (!device || device.revoked_at) {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
device.revoked_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1738
|
+
device.updated_at = device.revoked_at;
|
|
1739
|
+
await writeCredentialStore(paths, store);
|
|
1740
|
+
}
|
|
1741
|
+
async function readCredentialStore(paths) {
|
|
1742
|
+
const existing = await readJsonFile(paths.credentialsFile);
|
|
1743
|
+
return {
|
|
1744
|
+
devices: Array.isArray(existing?.devices) ? existing.devices.filter(isDeviceRecord) : []
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
async function writeCredentialStore(paths, store) {
|
|
1748
|
+
await writeJsonFile(paths.credentialsFile, store);
|
|
1749
|
+
}
|
|
1750
|
+
function formatDeviceSession(device, accessToken, refreshToken) {
|
|
1751
|
+
return {
|
|
1752
|
+
device: {
|
|
1753
|
+
id: device.id,
|
|
1754
|
+
device_id: device.id,
|
|
1755
|
+
label: device.label,
|
|
1756
|
+
platform: device.platform,
|
|
1757
|
+
scope: device.scope
|
|
1758
|
+
},
|
|
1759
|
+
accessToken: {
|
|
1760
|
+
token: accessToken,
|
|
1761
|
+
expiresAt: device.access_expires_at
|
|
1762
|
+
},
|
|
1763
|
+
refreshToken: {
|
|
1764
|
+
token: refreshToken,
|
|
1765
|
+
expiresAt: device.refresh_expires_at
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
function isDeviceRecord(value) {
|
|
1770
|
+
return typeof value === "object" && value !== null && typeof value.id === "string" && typeof value.access_token_hash === "string" && typeof value.refresh_token_hash === "string";
|
|
1771
|
+
}
|
|
1772
|
+
function randomToken(prefix) {
|
|
1773
|
+
return `${prefix}${randomBytes2(24).toString("base64url")}`;
|
|
1774
|
+
}
|
|
1775
|
+
function sha256(value) {
|
|
1776
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1777
|
+
}
|
|
1778
|
+
function safeEqual(left, right) {
|
|
1779
|
+
const leftBytes = Buffer.from(left);
|
|
1780
|
+
const rightBytes = Buffer.from(right);
|
|
1781
|
+
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/relay/bootstrap.ts
|
|
1785
|
+
async function bootstrapRelayLink(options) {
|
|
1786
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
1787
|
+
const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
|
|
1788
|
+
const commonPayload = {
|
|
1789
|
+
install_id: options.identity.install_id,
|
|
1790
|
+
link_id: options.identity.link_id ?? void 0,
|
|
1791
|
+
public_key_pem: options.identity.public_key_pem
|
|
1792
|
+
};
|
|
1793
|
+
const challenge = await postJson(
|
|
1794
|
+
fetcher,
|
|
1795
|
+
`${baseUrl}/api/v1/relay/link/challenge`,
|
|
1796
|
+
options.relayBootstrapToken,
|
|
1797
|
+
commonPayload
|
|
1798
|
+
);
|
|
1799
|
+
if (challenge.ok !== true || typeof challenge.nonce !== "string") {
|
|
1800
|
+
throw new Error("Relay did not return a valid install challenge");
|
|
1801
|
+
}
|
|
1802
|
+
const proof = {
|
|
1803
|
+
nonce: challenge.nonce,
|
|
1804
|
+
signature: signRelayNonce(options.identity, challenge.nonce)
|
|
1805
|
+
};
|
|
1806
|
+
const assigned = await postJson(
|
|
1807
|
+
fetcher,
|
|
1808
|
+
`${baseUrl}/api/v1/relay/link/bootstrap`,
|
|
1809
|
+
options.relayBootstrapToken,
|
|
1810
|
+
{
|
|
1811
|
+
...commonPayload,
|
|
1812
|
+
proof
|
|
1813
|
+
}
|
|
1814
|
+
);
|
|
1815
|
+
if (assigned.ok !== true || typeof assigned.link_id !== "string") {
|
|
1816
|
+
throw new Error("Relay did not return a valid link_id");
|
|
1817
|
+
}
|
|
1818
|
+
await saveAssignedLinkId(assigned.link_id, options.paths ?? resolveRuntimePaths());
|
|
1819
|
+
return {
|
|
1820
|
+
linkId: assigned.link_id,
|
|
1821
|
+
reused: assigned.reused === true
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
async function postJson(fetcher, url, token, body) {
|
|
1825
|
+
const response = await fetcher(url, {
|
|
1826
|
+
method: "POST",
|
|
1827
|
+
headers: {
|
|
1828
|
+
authorization: `Bearer ${token}`,
|
|
1829
|
+
"content-type": "application/json"
|
|
1830
|
+
},
|
|
1831
|
+
body: JSON.stringify(body)
|
|
1832
|
+
});
|
|
1833
|
+
const payload = await response.json().catch(() => null);
|
|
1834
|
+
if (!response.ok) {
|
|
1835
|
+
const message = readErrorMessage2(payload) ?? `Relay request failed with HTTP ${response.status}`;
|
|
1836
|
+
throw new Error(message);
|
|
1837
|
+
}
|
|
1838
|
+
if (!payload) {
|
|
1839
|
+
throw new Error("Relay returned an empty response");
|
|
1840
|
+
}
|
|
1841
|
+
return payload;
|
|
1842
|
+
}
|
|
1843
|
+
function readErrorMessage2(payload) {
|
|
1844
|
+
if (typeof payload !== "object" || payload === null) {
|
|
1845
|
+
return null;
|
|
1846
|
+
}
|
|
1847
|
+
const error = payload.error;
|
|
1848
|
+
if (typeof error !== "object" || error === null) {
|
|
1849
|
+
return null;
|
|
1850
|
+
}
|
|
1851
|
+
const message = error.message;
|
|
1852
|
+
return typeof message === "string" ? message : null;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// src/topology/network.ts
|
|
1856
|
+
import os4 from "os";
|
|
1857
|
+
var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
|
|
1858
|
+
var MAX_LAN_IPS = 4;
|
|
1859
|
+
var MAX_PUBLIC_IPV4S = 2;
|
|
1860
|
+
var MAX_PUBLIC_IPV6S = 2;
|
|
1861
|
+
async function discoverRouteCandidates(options) {
|
|
1862
|
+
const lanIps = discoverLanIps();
|
|
1863
|
+
const publicIps = options.relayBootstrapToken ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
|
|
1864
|
+
const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
|
|
1865
|
+
const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
|
|
1866
|
+
const preferredUrls = [
|
|
1867
|
+
...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
|
|
1868
|
+
...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
1869
|
+
...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
1870
|
+
`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
|
|
1871
|
+
];
|
|
1872
|
+
return {
|
|
1873
|
+
lanIps,
|
|
1874
|
+
publicIpv4s,
|
|
1875
|
+
publicIpv6s,
|
|
1876
|
+
preferredUrls
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
function discoverLanIps() {
|
|
1880
|
+
return discoverLanIpsFromInterfaces(os4.networkInterfaces());
|
|
1881
|
+
}
|
|
1882
|
+
function discoverLanIpsFromInterfaces(interfaces) {
|
|
1883
|
+
const result = /* @__PURE__ */ new Set();
|
|
1884
|
+
const candidates = [];
|
|
1885
|
+
for (const [name, items] of Object.entries(interfaces)) {
|
|
1886
|
+
if (shouldIgnoreInterface(name)) {
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
for (const item of items ?? []) {
|
|
1890
|
+
if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv4(item.address, item.netmask)) {
|
|
1891
|
+
candidates.push({ name, address: item.address });
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
for (const candidate of candidates.sort(compareLanCandidate)) {
|
|
1896
|
+
result.add(candidate.address);
|
|
1897
|
+
}
|
|
1898
|
+
return [...result].slice(0, MAX_LAN_IPS);
|
|
1899
|
+
}
|
|
1900
|
+
async function observePublicRoute(options) {
|
|
1901
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
1902
|
+
const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
|
|
1903
|
+
method: "POST",
|
|
1904
|
+
headers: {
|
|
1905
|
+
authorization: `Bearer ${options.relayBootstrapToken}`,
|
|
1906
|
+
"content-type": "application/json"
|
|
1907
|
+
},
|
|
1908
|
+
body: JSON.stringify({
|
|
1909
|
+
install_id: options.installId,
|
|
1910
|
+
link_id: options.linkId,
|
|
1911
|
+
public_key_pem: options.publicKeyPem
|
|
1912
|
+
})
|
|
1913
|
+
});
|
|
1914
|
+
const payload = await response.json().catch(() => null);
|
|
1915
|
+
const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
|
|
1916
|
+
const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
|
|
1917
|
+
const values = [
|
|
1918
|
+
readIpRecord(record?.ipv4),
|
|
1919
|
+
readIpRecord(record?.ipv6),
|
|
1920
|
+
typeof observed?.ip === "string" ? observed.ip : null
|
|
1921
|
+
].filter((value) => Boolean(value));
|
|
1922
|
+
return {
|
|
1923
|
+
publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
|
|
1924
|
+
publicIpv6s: unique(values.filter(isUsablePublicIpv6))
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
function readIpRecord(value) {
|
|
1928
|
+
if (typeof value !== "object" || value === null) {
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
const ip = value.ip;
|
|
1932
|
+
return typeof ip === "string" && ip.trim() ? ip.trim() : null;
|
|
1933
|
+
}
|
|
1934
|
+
function buildDirectUrl(ip, port) {
|
|
1935
|
+
return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
|
|
1936
|
+
}
|
|
1937
|
+
function shouldIgnoreInterface(name) {
|
|
1938
|
+
return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
|
|
1939
|
+
}
|
|
1940
|
+
function compareLanCandidate(left, right) {
|
|
1941
|
+
const priority = interfacePriority(left.name) - interfacePriority(right.name);
|
|
1942
|
+
return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
|
|
1943
|
+
}
|
|
1944
|
+
function interfacePriority(name) {
|
|
1945
|
+
if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
|
|
1946
|
+
return 0;
|
|
1947
|
+
}
|
|
1948
|
+
return 1;
|
|
1949
|
+
}
|
|
1950
|
+
function isUsableLanIpv4(address, netmask) {
|
|
1951
|
+
return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
|
|
1952
|
+
}
|
|
1953
|
+
function isUsablePublicIpv4(address) {
|
|
1954
|
+
return isValidIpv4(address) && !isSpecialIpv4(address);
|
|
1955
|
+
}
|
|
1956
|
+
function isUsablePublicIpv6(address) {
|
|
1957
|
+
const normalized = address.toLowerCase();
|
|
1958
|
+
return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
|
|
1959
|
+
}
|
|
1960
|
+
function isPrivateIpv4(address) {
|
|
1961
|
+
const parts = parseIpv4Segments(address);
|
|
1962
|
+
if (!parts) {
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
const [first, second] = parts;
|
|
1966
|
+
return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
1967
|
+
}
|
|
1968
|
+
function isSpecialIpv4(address) {
|
|
1969
|
+
const parts = parseIpv4Segments(address);
|
|
1970
|
+
if (!parts) {
|
|
1971
|
+
return true;
|
|
1972
|
+
}
|
|
1973
|
+
const [first, second, third, fourth] = parts;
|
|
1974
|
+
return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
|
|
1975
|
+
}
|
|
1976
|
+
function isNetworkOrBroadcastIpv4Address(address, netmask) {
|
|
1977
|
+
const addressParts = parseIpv4Segments(address);
|
|
1978
|
+
const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
|
|
1979
|
+
if (!addressParts) {
|
|
1980
|
+
return true;
|
|
1981
|
+
}
|
|
1982
|
+
if (!netmaskParts) {
|
|
1983
|
+
const last = addressParts[3];
|
|
1984
|
+
return last === 0 || last === 255;
|
|
1985
|
+
}
|
|
1986
|
+
const addressInt = ipv4SegmentsToInt(addressParts);
|
|
1987
|
+
const netmaskInt = ipv4SegmentsToInt(netmaskParts);
|
|
1988
|
+
const hostMask = ~netmaskInt >>> 0;
|
|
1989
|
+
if (hostMask === 0) {
|
|
1990
|
+
return false;
|
|
1991
|
+
}
|
|
1992
|
+
const networkInt = addressInt & netmaskInt;
|
|
1993
|
+
const broadcastInt = (networkInt | hostMask) >>> 0;
|
|
1994
|
+
return addressInt === networkInt || addressInt === broadcastInt;
|
|
1995
|
+
}
|
|
1996
|
+
function isValidIpv4(address) {
|
|
1997
|
+
return Boolean(parseIpv4Segments(address));
|
|
1998
|
+
}
|
|
1999
|
+
function parseIpv4Segments(address) {
|
|
2000
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
|
|
2001
|
+
return null;
|
|
2002
|
+
}
|
|
2003
|
+
const parts = address.split(".").map((part) => Number.parseInt(part, 10));
|
|
2004
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
2005
|
+
return null;
|
|
2006
|
+
}
|
|
2007
|
+
return parts;
|
|
2008
|
+
}
|
|
2009
|
+
function ipv4SegmentsToInt(parts) {
|
|
2010
|
+
return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
|
|
2011
|
+
}
|
|
2012
|
+
function unique(values) {
|
|
2013
|
+
return [...new Set(values)];
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// src/pairing/pairing.ts
|
|
2017
|
+
async function preparePairing(paths = resolveRuntimePaths()) {
|
|
2018
|
+
const config = await loadConfig(paths);
|
|
2019
|
+
const identity = await ensureIdentity(paths);
|
|
2020
|
+
const created = await postServerJson(config.serverBaseUrl, "/api/v1/link-pairings", {
|
|
2021
|
+
install_id: identity.install_id,
|
|
2022
|
+
link_id: identity.link_id ?? void 0,
|
|
2023
|
+
display_name: defaultDisplayName(),
|
|
2024
|
+
public_key_pem: identity.public_key_pem
|
|
2025
|
+
});
|
|
2026
|
+
const relayBaseUrl = created.relayBaseUrl || config.relayBaseUrl;
|
|
2027
|
+
const assigned = await bootstrapRelayLink({
|
|
2028
|
+
relayBaseUrl,
|
|
2029
|
+
relayBootstrapToken: created.relayBootstrapToken,
|
|
2030
|
+
identity,
|
|
2031
|
+
paths
|
|
2032
|
+
});
|
|
2033
|
+
const updatedIdentity = await loadIdentity(paths) ?? identity;
|
|
2034
|
+
const routes = await discoverRouteCandidates({
|
|
2035
|
+
port: config.port,
|
|
2036
|
+
relayBaseUrl,
|
|
2037
|
+
relayBootstrapToken: created.relayBootstrapToken,
|
|
2038
|
+
linkId: assigned.linkId,
|
|
2039
|
+
installId: updatedIdentity.install_id,
|
|
2040
|
+
publicKeyPem: updatedIdentity.public_key_pem
|
|
2041
|
+
});
|
|
2042
|
+
await patchServerJson(config.serverBaseUrl, `/api/v1/link-pairings/${created.sessionId}/link`, created.pairingToken, {
|
|
2043
|
+
install_id: updatedIdentity.install_id,
|
|
2044
|
+
link_id: assigned.linkId,
|
|
2045
|
+
display_name: defaultDisplayName(),
|
|
2046
|
+
lan_ips: routes.lanIps,
|
|
2047
|
+
public_ipv4s: routes.publicIpv4s,
|
|
2048
|
+
public_ipv6s: routes.publicIpv6s,
|
|
2049
|
+
preferred_urls: routes.preferredUrls
|
|
2050
|
+
});
|
|
2051
|
+
const qrPayload = {
|
|
2052
|
+
kind: "hermes_link_pairing",
|
|
2053
|
+
version: 1,
|
|
2054
|
+
link_id: assigned.linkId,
|
|
2055
|
+
display_name: defaultDisplayName(),
|
|
2056
|
+
session_id: created.sessionId,
|
|
2057
|
+
code: created.code,
|
|
2058
|
+
preferred_urls: qrPreferredUrls(routes)
|
|
2059
|
+
};
|
|
2060
|
+
return {
|
|
2061
|
+
sessionId: created.sessionId,
|
|
2062
|
+
code: created.code,
|
|
2063
|
+
pairingToken: created.pairingToken,
|
|
2064
|
+
relayBootstrapToken: created.relayBootstrapToken,
|
|
2065
|
+
relayBaseUrl,
|
|
2066
|
+
displayName: defaultDisplayName(),
|
|
2067
|
+
linkId: assigned.linkId,
|
|
2068
|
+
routes,
|
|
2069
|
+
qrPayload
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
async function recordPairingClaim(input, paths = resolveRuntimePaths()) {
|
|
2073
|
+
const record = {
|
|
2074
|
+
session_id: input.sessionId,
|
|
2075
|
+
device_id: input.deviceId,
|
|
2076
|
+
device_label: input.deviceLabel,
|
|
2077
|
+
device_platform: input.devicePlatform,
|
|
2078
|
+
claimed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2079
|
+
};
|
|
2080
|
+
await writeJsonFile(pairingClaimPath(input.sessionId, paths), record);
|
|
2081
|
+
return record;
|
|
2082
|
+
}
|
|
2083
|
+
async function readPairingClaim(sessionId, paths = resolveRuntimePaths()) {
|
|
2084
|
+
const record = await readJsonFile(pairingClaimPath(sessionId, paths));
|
|
2085
|
+
if (!record || record.session_id !== sessionId || typeof record.device_id !== "string") {
|
|
2086
|
+
return null;
|
|
2087
|
+
}
|
|
2088
|
+
return {
|
|
2089
|
+
session_id: record.session_id,
|
|
2090
|
+
device_id: record.device_id,
|
|
2091
|
+
device_label: typeof record.device_label === "string" ? record.device_label : "",
|
|
2092
|
+
device_platform: typeof record.device_platform === "string" ? record.device_platform : "unknown",
|
|
2093
|
+
claimed_at: typeof record.claimed_at === "string" ? record.claimed_at : ""
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
async function clearPairingClaim(sessionId, paths = resolveRuntimePaths()) {
|
|
2097
|
+
await rm4(pairingClaimPath(sessionId, paths), { force: true }).catch(() => void 0);
|
|
2098
|
+
}
|
|
2099
|
+
async function claimPairing(input) {
|
|
2100
|
+
const paths = input.paths ?? resolveRuntimePaths();
|
|
2101
|
+
const [identity, config] = await Promise.all([loadRequiredIdentity(paths), loadConfig(paths)]);
|
|
2102
|
+
const verified = await postServerJson(
|
|
2103
|
+
config.serverBaseUrl,
|
|
2104
|
+
`/api/v1/link-pairings/${input.sessionId}/claim/verify`,
|
|
2105
|
+
{
|
|
2106
|
+
claim_token: input.claimToken
|
|
2107
|
+
}
|
|
2108
|
+
);
|
|
2109
|
+
if (verified.ok !== true || verified.linkId !== identity.link_id) {
|
|
2110
|
+
throw new LinkHttpError(409, "pairing_claim_mismatch", "Pairing claim does not match this Link");
|
|
2111
|
+
}
|
|
2112
|
+
const session = await createDeviceSession(
|
|
2113
|
+
{
|
|
2114
|
+
label: input.deviceLabel || "HermesPilot App",
|
|
2115
|
+
platform: input.devicePlatform || "unknown"
|
|
2116
|
+
},
|
|
2117
|
+
paths
|
|
2118
|
+
);
|
|
2119
|
+
return {
|
|
2120
|
+
link: {
|
|
2121
|
+
link_id: identity.link_id,
|
|
2122
|
+
display_name: defaultDisplayName()
|
|
2123
|
+
},
|
|
2124
|
+
device: formatDevice(session.device),
|
|
2125
|
+
access_token: {
|
|
2126
|
+
token: session.accessToken.token,
|
|
2127
|
+
expires_at: session.accessToken.expiresAt
|
|
2128
|
+
},
|
|
2129
|
+
refresh_token: {
|
|
2130
|
+
token: session.refreshToken.token,
|
|
2131
|
+
expires_at: session.refreshToken.expiresAt
|
|
2132
|
+
}
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
async function loadRequiredIdentity(paths) {
|
|
2136
|
+
const identity = await loadIdentity(paths);
|
|
2137
|
+
if (!identity?.link_id) {
|
|
2138
|
+
throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
2139
|
+
}
|
|
2140
|
+
return identity;
|
|
2141
|
+
}
|
|
2142
|
+
async function postServerJson(serverBaseUrl, path9, body) {
|
|
2143
|
+
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path9}`, {
|
|
2144
|
+
method: "POST",
|
|
2145
|
+
headers: {
|
|
2146
|
+
accept: "application/json",
|
|
2147
|
+
"content-type": "application/json"
|
|
2148
|
+
},
|
|
2149
|
+
body: JSON.stringify(body)
|
|
2150
|
+
});
|
|
2151
|
+
return readJsonResponse2(response);
|
|
2152
|
+
}
|
|
2153
|
+
async function patchServerJson(serverBaseUrl, path9, token, body) {
|
|
2154
|
+
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path9}`, {
|
|
2155
|
+
method: "PATCH",
|
|
2156
|
+
headers: {
|
|
2157
|
+
accept: "application/json",
|
|
2158
|
+
authorization: `Bearer ${token}`,
|
|
2159
|
+
"content-type": "application/json"
|
|
2160
|
+
},
|
|
2161
|
+
body: JSON.stringify(body)
|
|
2162
|
+
});
|
|
2163
|
+
return readJsonResponse2(response);
|
|
2164
|
+
}
|
|
2165
|
+
async function readJsonResponse2(response) {
|
|
2166
|
+
const payload = await response.json().catch(() => null);
|
|
2167
|
+
if (!response.ok || !payload) {
|
|
2168
|
+
const message = readErrorMessage3(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
|
|
2169
|
+
throw new LinkHttpError(response.status, "server_request_failed", message);
|
|
2170
|
+
}
|
|
2171
|
+
return payload;
|
|
2172
|
+
}
|
|
2173
|
+
function readErrorMessage3(payload) {
|
|
2174
|
+
if (typeof payload !== "object" || payload === null) {
|
|
2175
|
+
return null;
|
|
2176
|
+
}
|
|
2177
|
+
const error = payload.error;
|
|
2178
|
+
if (typeof error !== "object" || error === null) {
|
|
2179
|
+
return null;
|
|
2180
|
+
}
|
|
2181
|
+
const message = error.message;
|
|
2182
|
+
return typeof message === "string" ? message : null;
|
|
2183
|
+
}
|
|
2184
|
+
function defaultDisplayName() {
|
|
2185
|
+
return `Hermes Link ${process.platform}`;
|
|
2186
|
+
}
|
|
2187
|
+
function pairingClaimPath(sessionId, paths) {
|
|
2188
|
+
return path7.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
|
|
2189
|
+
}
|
|
2190
|
+
function qrPreferredUrls(routes) {
|
|
2191
|
+
return routes.preferredUrls.filter((url) => !url.includes("/api/v1/relay/links/")).slice(0, 1);
|
|
2192
|
+
}
|
|
2193
|
+
function formatDevice(device) {
|
|
2194
|
+
return {
|
|
2195
|
+
id: device.id,
|
|
2196
|
+
device_id: device.device_id,
|
|
2197
|
+
label: device.label,
|
|
2198
|
+
platform: device.platform,
|
|
2199
|
+
scope: device.scope
|
|
2200
|
+
};
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// src/security/app-connect-token.ts
|
|
2204
|
+
import { createPublicKey, verify } from "crypto";
|
|
2205
|
+
var cachedJwks = null;
|
|
2206
|
+
async function verifyAppConnectToken(token, options) {
|
|
2207
|
+
const segments = token.split(".");
|
|
2208
|
+
if (segments.length !== 3) {
|
|
2209
|
+
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token is malformed");
|
|
2210
|
+
}
|
|
2211
|
+
const [encodedHeader, encodedPayload, encodedSignature] = segments;
|
|
2212
|
+
const header = decodeJson2(encodedHeader);
|
|
2213
|
+
const payload = decodeJson2(encodedPayload);
|
|
2214
|
+
if (header.alg !== "ES256" || header.typ !== "JWT") {
|
|
2215
|
+
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token algorithm is unsupported");
|
|
2216
|
+
}
|
|
2217
|
+
if (payload.token_type !== "hermes_app_connect" || payload.iss !== options.config.appConnectTokenIssuer || payload.aud !== options.config.appConnectTokenAudience || payload.link_id !== options.linkId || !Number.isFinite(payload.exp) || payload.exp <= Math.floor(Date.now() / 1e3)) {
|
|
2218
|
+
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token claims are invalid");
|
|
2219
|
+
}
|
|
2220
|
+
const jwks = await getJwks(options.config, options.fetchImpl ?? fetch);
|
|
2221
|
+
const key = jwks.find((item) => item.kid === header.kid) ?? jwks[0];
|
|
2222
|
+
if (!key) {
|
|
2223
|
+
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "App connect token key is unavailable");
|
|
2224
|
+
}
|
|
2225
|
+
const publicKey = createPublicKey({ key, format: "jwk" });
|
|
2226
|
+
const ok = verify(
|
|
2227
|
+
"sha256",
|
|
2228
|
+
Buffer.from(`${encodedHeader}.${encodedPayload}`),
|
|
2229
|
+
{ key: publicKey, dsaEncoding: "ieee-p1363" },
|
|
2230
|
+
Buffer.from(base64UrlToBase64(encodedSignature), "base64")
|
|
2231
|
+
);
|
|
2232
|
+
if (!ok) {
|
|
2233
|
+
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token signature is invalid");
|
|
2234
|
+
}
|
|
2235
|
+
return payload;
|
|
2236
|
+
}
|
|
2237
|
+
async function getJwks(config, fetcher) {
|
|
2238
|
+
if (cachedJwks && cachedJwks.expiresAt > Date.now()) {
|
|
2239
|
+
return cachedJwks.keys;
|
|
2240
|
+
}
|
|
2241
|
+
const response = await fetcher(`${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/app-connect/jwks.json`, {
|
|
2242
|
+
headers: { accept: "application/json" }
|
|
2243
|
+
});
|
|
2244
|
+
if (!response.ok) {
|
|
2245
|
+
throw new LinkHttpError(503, "app_connect_jwks_unavailable", "Unable to load app connect JWKS");
|
|
2246
|
+
}
|
|
2247
|
+
const payload = await response.json();
|
|
2248
|
+
const keys = Array.isArray(payload.keys) ? payload.keys : [];
|
|
2249
|
+
cachedJwks = {
|
|
2250
|
+
keys,
|
|
2251
|
+
expiresAt: Date.now() + 5 * 60 * 1e3
|
|
2252
|
+
};
|
|
2253
|
+
return keys;
|
|
2254
|
+
}
|
|
2255
|
+
function decodeJson2(value) {
|
|
2256
|
+
return JSON.parse(Buffer.from(base64UrlToBase64(value), "base64").toString("utf8"));
|
|
2257
|
+
}
|
|
2258
|
+
function base64UrlToBase64(value) {
|
|
2259
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
2260
|
+
return normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// src/runtime/logger.ts
|
|
2264
|
+
import { appendFile as appendFile2, mkdir as mkdir7, open as open3, readFile as readFile4, rename as rename3, rm as rm5, stat as stat3 } from "fs/promises";
|
|
2265
|
+
import path8 from "path";
|
|
2266
|
+
var DEFAULT_LOG_FILE = "hermeslink.log";
|
|
2267
|
+
var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
|
|
2268
|
+
var DEFAULT_MAX_FILES = 5;
|
|
2269
|
+
var DEFAULT_READ_LIMIT = 200;
|
|
2270
|
+
var MAX_READ_LIMIT = 1e3;
|
|
2271
|
+
var DEFAULT_MAX_BYTES_PER_FILE = 512 * 1024;
|
|
2272
|
+
var FileLogger = class {
|
|
2273
|
+
filePath;
|
|
2274
|
+
paths;
|
|
2275
|
+
maxFileBytes;
|
|
2276
|
+
maxFiles;
|
|
2277
|
+
now;
|
|
2278
|
+
queue = Promise.resolve();
|
|
2279
|
+
constructor(options = {}) {
|
|
2280
|
+
this.paths = options.paths ?? resolveRuntimePaths();
|
|
2281
|
+
this.filePath = getLinkLogFile(this.paths, options.fileName);
|
|
2282
|
+
this.maxFileBytes = Math.max(256, Math.floor(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES));
|
|
2283
|
+
this.maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
2284
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
2285
|
+
}
|
|
2286
|
+
debug(message, fields) {
|
|
2287
|
+
return this.write("debug", message, fields);
|
|
2288
|
+
}
|
|
2289
|
+
info(message, fields) {
|
|
2290
|
+
return this.write("info", message, fields);
|
|
2291
|
+
}
|
|
2292
|
+
warn(message, fields) {
|
|
2293
|
+
return this.write("warn", message, fields);
|
|
2294
|
+
}
|
|
2295
|
+
error(message, fields) {
|
|
2296
|
+
return this.write("error", message, fields);
|
|
2297
|
+
}
|
|
2298
|
+
write(level, message, fields) {
|
|
2299
|
+
const entry = {
|
|
2300
|
+
ts: this.now().toISOString(),
|
|
2301
|
+
level,
|
|
2302
|
+
message,
|
|
2303
|
+
...fields ? { fields: sanitizeFields(fields) } : {}
|
|
2304
|
+
};
|
|
2305
|
+
const next = this.queue.then(() => this.appendEntry(entry)).catch(() => void 0);
|
|
2306
|
+
this.queue = next;
|
|
2307
|
+
return next;
|
|
2308
|
+
}
|
|
2309
|
+
flush() {
|
|
2310
|
+
return this.queue;
|
|
2311
|
+
}
|
|
2312
|
+
async appendEntry(entry) {
|
|
2313
|
+
await mkdir7(this.paths.logsDir, { recursive: true, mode: 448 });
|
|
2314
|
+
const line = `${JSON.stringify(entry)}
|
|
2315
|
+
`;
|
|
2316
|
+
await this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
|
|
2317
|
+
await appendFile2(this.filePath, line, { mode: 384 });
|
|
2318
|
+
}
|
|
2319
|
+
async rotateIfNeeded(nextBytes) {
|
|
2320
|
+
const current = await stat3(this.filePath).catch(() => null);
|
|
2321
|
+
if (!current || current.size === 0 || current.size + nextBytes <= this.maxFileBytes) {
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
if (this.maxFiles === 0) {
|
|
2325
|
+
await rm5(this.filePath, { force: true }).catch(() => void 0);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
await rm5(rotatedLogFile(this.filePath, this.maxFiles), { force: true }).catch(() => void 0);
|
|
2329
|
+
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
|
2330
|
+
await moveIfExists(rotatedLogFile(this.filePath, index), rotatedLogFile(this.filePath, index + 1));
|
|
2331
|
+
}
|
|
2332
|
+
await moveIfExists(this.filePath, rotatedLogFile(this.filePath, 1));
|
|
2333
|
+
}
|
|
2334
|
+
};
|
|
2335
|
+
function createFileLogger(options = {}) {
|
|
2336
|
+
return new FileLogger(options);
|
|
2337
|
+
}
|
|
2338
|
+
function getLinkLogFile(paths = resolveRuntimePaths(), fileName = DEFAULT_LOG_FILE) {
|
|
2339
|
+
return path8.join(paths.logsDir, fileName);
|
|
2340
|
+
}
|
|
2341
|
+
async function readRecentLogEntries(options = {}) {
|
|
2342
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
2343
|
+
const filePath = getLinkLogFile(paths, options.fileName);
|
|
2344
|
+
const limit = clampLimit(options.limit);
|
|
2345
|
+
const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
2346
|
+
const maxBytesPerFile = Math.max(1024, Math.floor(options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE));
|
|
2347
|
+
const files = [filePath, ...Array.from({ length: maxFiles }, (_, index) => rotatedLogFile(filePath, index + 1))];
|
|
2348
|
+
const entries = [];
|
|
2349
|
+
for (const file of files) {
|
|
2350
|
+
const raw = await readTail(file, maxBytesPerFile);
|
|
2351
|
+
if (!raw) {
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
const lines = raw.split(/\r?\n/u).filter(Boolean);
|
|
2355
|
+
for (let index = lines.length - 1; index >= 0 && entries.length < limit; index -= 1) {
|
|
2356
|
+
const entry = parseLogLine(lines[index]);
|
|
2357
|
+
if (entry) {
|
|
2358
|
+
entries.push(entry);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
if (entries.length >= limit) {
|
|
2362
|
+
break;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
return entries.reverse();
|
|
2366
|
+
}
|
|
2367
|
+
function clampLimit(value) {
|
|
2368
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2369
|
+
return DEFAULT_READ_LIMIT;
|
|
2370
|
+
}
|
|
2371
|
+
return Math.min(MAX_READ_LIMIT, Math.max(1, Math.floor(value)));
|
|
2372
|
+
}
|
|
2373
|
+
function sanitizeFields(fields) {
|
|
2374
|
+
return sanitizeObject(fields, 0);
|
|
2375
|
+
}
|
|
2376
|
+
function sanitizeValue(value, depth) {
|
|
2377
|
+
if (value === null || typeof value === "boolean") {
|
|
2378
|
+
return value;
|
|
2379
|
+
}
|
|
2380
|
+
if (typeof value === "number") {
|
|
2381
|
+
return Number.isFinite(value) ? value : null;
|
|
2382
|
+
}
|
|
2383
|
+
if (typeof value === "string") {
|
|
2384
|
+
return value.length > 2e3 ? `${value.slice(0, 2e3)}...` : value;
|
|
2385
|
+
}
|
|
2386
|
+
if (Array.isArray(value)) {
|
|
2387
|
+
if (depth >= 3) {
|
|
2388
|
+
return "[array]";
|
|
2389
|
+
}
|
|
2390
|
+
return value.slice(0, 20).map((item) => sanitizeValue(item, depth + 1));
|
|
2391
|
+
}
|
|
2392
|
+
if (typeof value === "object" && value !== null) {
|
|
2393
|
+
if (depth >= 3) {
|
|
2394
|
+
return "[object]";
|
|
2395
|
+
}
|
|
2396
|
+
return sanitizeObject(value, depth + 1);
|
|
2397
|
+
}
|
|
2398
|
+
return String(value);
|
|
2399
|
+
}
|
|
2400
|
+
function sanitizeObject(value, depth) {
|
|
2401
|
+
const result = {};
|
|
2402
|
+
for (const [key, child] of Object.entries(value).slice(0, 50)) {
|
|
2403
|
+
if (isSensitiveKey(key)) {
|
|
2404
|
+
result[key] = "[redacted]";
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
result[key] = sanitizeValue(child, depth);
|
|
2408
|
+
}
|
|
2409
|
+
return result;
|
|
2410
|
+
}
|
|
2411
|
+
function isSensitiveKey(key) {
|
|
2412
|
+
return /(authorization|cookie|token|secret|password|private[_-]?key|api[_-]?key)/iu.test(key);
|
|
2413
|
+
}
|
|
2414
|
+
function parseLogLine(line) {
|
|
2415
|
+
try {
|
|
2416
|
+
const value = JSON.parse(line);
|
|
2417
|
+
if (!value || typeof value.ts !== "string" || !isLogLevel(value.level) || typeof value.message !== "string") {
|
|
2418
|
+
return null;
|
|
2419
|
+
}
|
|
2420
|
+
return {
|
|
2421
|
+
ts: value.ts,
|
|
2422
|
+
level: value.level,
|
|
2423
|
+
message: value.message,
|
|
2424
|
+
...value.fields && typeof value.fields === "object" ? { fields: value.fields } : {}
|
|
2425
|
+
};
|
|
2426
|
+
} catch {
|
|
2427
|
+
return null;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
function isLogLevel(value) {
|
|
2431
|
+
return value === "debug" || value === "info" || value === "warn" || value === "error";
|
|
2432
|
+
}
|
|
2433
|
+
async function readTail(filePath, maxBytes) {
|
|
2434
|
+
const info = await stat3(filePath).catch(() => null);
|
|
2435
|
+
if (!info || info.size <= 0) {
|
|
2436
|
+
return null;
|
|
2437
|
+
}
|
|
2438
|
+
if (info.size <= maxBytes) {
|
|
2439
|
+
return await readFile4(filePath, "utf8").catch(() => null);
|
|
2440
|
+
}
|
|
2441
|
+
const handle = await open3(filePath, "r").catch(() => null);
|
|
2442
|
+
if (!handle) {
|
|
2443
|
+
return null;
|
|
2444
|
+
}
|
|
2445
|
+
try {
|
|
2446
|
+
const length = Math.min(info.size, maxBytes);
|
|
2447
|
+
const buffer = Buffer.alloc(length);
|
|
2448
|
+
await handle.read(buffer, 0, length, info.size - length);
|
|
2449
|
+
return buffer.toString("utf8");
|
|
2450
|
+
} finally {
|
|
2451
|
+
await handle.close();
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
async function moveIfExists(from, to) {
|
|
2455
|
+
await rm5(to, { force: true }).catch(() => void 0);
|
|
2456
|
+
await rename3(from, to).catch((error) => {
|
|
2457
|
+
if (error.code !== "ENOENT") {
|
|
2458
|
+
throw error;
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
function rotatedLogFile(filePath, index) {
|
|
2463
|
+
return `${filePath}.${index}`;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
// src/http/app.ts
|
|
2467
|
+
var MAX_JSON_BODY_BYTES = 1024 * 1024;
|
|
2468
|
+
var MAX_BLOB_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
2469
|
+
async function createApp(options = {}) {
|
|
2470
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
2471
|
+
const logger = options.logger ?? createFileLogger({ paths });
|
|
2472
|
+
const conversations = new ConversationService(paths, logger);
|
|
2473
|
+
const app = new Koa();
|
|
2474
|
+
const router = new Router();
|
|
2475
|
+
app.use(async (ctx, next) => {
|
|
2476
|
+
const startedAt = Date.now();
|
|
2477
|
+
try {
|
|
2478
|
+
await next();
|
|
2479
|
+
} catch (error) {
|
|
2480
|
+
const profileError = error instanceof Error && error.message === "invalid profile name";
|
|
2481
|
+
const status = isLinkHttpError(error) ? error.status : profileError ? 400 : 500;
|
|
2482
|
+
ctx.status = status;
|
|
2483
|
+
ctx.body = {
|
|
2484
|
+
ok: false,
|
|
2485
|
+
error: {
|
|
2486
|
+
code: isLinkHttpError(error) ? error.code : status === 400 ? "invalid_profile_name" : "internal_error",
|
|
2487
|
+
message: error instanceof Error ? error.message : "Internal error"
|
|
2488
|
+
}
|
|
2489
|
+
};
|
|
2490
|
+
void logger.write(status >= 500 ? "error" : "warn", "http_request_failed", {
|
|
2491
|
+
method: ctx.method,
|
|
2492
|
+
path: ctx.path,
|
|
2493
|
+
status,
|
|
2494
|
+
code: isLinkHttpError(error) ? error.code : status === 400 ? "invalid_profile_name" : "internal_error",
|
|
2495
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2496
|
+
});
|
|
2497
|
+
} finally {
|
|
2498
|
+
void logger.info("http_request", {
|
|
2499
|
+
method: ctx.method,
|
|
2500
|
+
path: ctx.path,
|
|
2501
|
+
status: ctx.status,
|
|
2502
|
+
duration_ms: Date.now() - startedAt
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
router.get("/api/v1/bootstrap", async (ctx) => {
|
|
2507
|
+
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
2508
|
+
const routes = identity?.link_id ? await discoverRouteCandidates({
|
|
2509
|
+
port: config.port,
|
|
2510
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
2511
|
+
linkId: identity.link_id,
|
|
2512
|
+
installId: identity.install_id,
|
|
2513
|
+
publicKeyPem: identity.public_key_pem
|
|
2514
|
+
}) : null;
|
|
2515
|
+
ctx.set("cache-control", "no-store");
|
|
2516
|
+
ctx.body = {
|
|
2517
|
+
link_id: identity?.link_id ?? null,
|
|
2518
|
+
display_name: identity?.link_id ? "Hermes Link" : "Unpaired Hermes Link",
|
|
2519
|
+
version: LINK_VERSION,
|
|
2520
|
+
api_version: 1,
|
|
2521
|
+
paired: Boolean(identity?.link_id),
|
|
2522
|
+
pairing_supported: Boolean(identity?.link_id),
|
|
2523
|
+
preferred_pairing_urls: routes?.preferredUrls ?? [],
|
|
2524
|
+
routes: routes ? routeObjects(routes.preferredUrls) : [],
|
|
2525
|
+
capabilities: {
|
|
2526
|
+
runs: true,
|
|
2527
|
+
sse: true,
|
|
2528
|
+
relay: true,
|
|
2529
|
+
profiles: true,
|
|
2530
|
+
logs: true,
|
|
2531
|
+
conversations: true,
|
|
2532
|
+
conversation_delete: true,
|
|
2533
|
+
blobs: true
|
|
2534
|
+
}
|
|
2535
|
+
};
|
|
2536
|
+
});
|
|
2537
|
+
router.post("/api/v1/pairing/claim", async (ctx) => {
|
|
2538
|
+
const body = await readJsonBody(ctx.req);
|
|
2539
|
+
const sessionId = readString3(body, "session_id") ?? readString3(body, "sessionId");
|
|
2540
|
+
const claimToken = readString3(body, "claim_token") ?? readString3(body, "claimToken");
|
|
2541
|
+
if (!sessionId || !claimToken) {
|
|
2542
|
+
throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
|
|
2543
|
+
}
|
|
2544
|
+
const claimed = await claimPairing({
|
|
2545
|
+
sessionId,
|
|
2546
|
+
claimToken,
|
|
2547
|
+
deviceLabel: readString3(body, "device_label") ?? readString3(body, "deviceLabel") ?? "HermesPilot App",
|
|
2548
|
+
devicePlatform: readString3(body, "device_platform") ?? readString3(body, "devicePlatform") ?? "unknown",
|
|
2549
|
+
paths
|
|
2550
|
+
});
|
|
2551
|
+
ctx.body = claimed;
|
|
2552
|
+
void logger.info("pairing_claimed", {
|
|
2553
|
+
device_id: claimed.device.device_id,
|
|
2554
|
+
device_platform: claimed.device.platform
|
|
2555
|
+
});
|
|
2556
|
+
const timer = setTimeout(() => {
|
|
2557
|
+
void recordPairingClaim(
|
|
2558
|
+
{
|
|
2559
|
+
sessionId,
|
|
2560
|
+
deviceId: claimed.device.device_id,
|
|
2561
|
+
deviceLabel: claimed.device.label,
|
|
2562
|
+
devicePlatform: claimed.device.platform
|
|
2563
|
+
},
|
|
2564
|
+
paths
|
|
2565
|
+
).catch((error) => {
|
|
2566
|
+
void logger.warn("pairing_claim_record_failed", {
|
|
2567
|
+
session_id: sessionId,
|
|
2568
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2569
|
+
});
|
|
2570
|
+
});
|
|
2571
|
+
void options.onPairingClaimed?.();
|
|
2572
|
+
}, 250);
|
|
2573
|
+
timer.unref?.();
|
|
2574
|
+
});
|
|
2575
|
+
router.get("/api/v1/auth/me", async (ctx) => {
|
|
2576
|
+
const auth = await authenticateRequest(ctx, paths);
|
|
2577
|
+
const identity = await loadRequiredIdentity2(paths);
|
|
2578
|
+
ctx.body = {
|
|
2579
|
+
ok: true,
|
|
2580
|
+
auth: { kind: auth.kind, account_id: auth.accountId ?? null },
|
|
2581
|
+
link: {
|
|
2582
|
+
link_id: identity.link_id,
|
|
2583
|
+
display_name: "Hermes Link"
|
|
2584
|
+
},
|
|
2585
|
+
device: auth.device ? {
|
|
2586
|
+
id: auth.device.id,
|
|
2587
|
+
device_id: auth.device.id,
|
|
2588
|
+
label: auth.device.label,
|
|
2589
|
+
platform: auth.device.platform,
|
|
2590
|
+
scope: auth.device.scope
|
|
2591
|
+
} : null
|
|
2592
|
+
};
|
|
2593
|
+
});
|
|
2594
|
+
router.post("/api/v1/auth/refresh", async (ctx) => {
|
|
2595
|
+
const body = await readJsonBody(ctx.req);
|
|
2596
|
+
const refreshToken = readString3(body, "refresh_token") ?? readString3(body, "refreshToken");
|
|
2597
|
+
if (!refreshToken) {
|
|
2598
|
+
throw new LinkHttpError(400, "refresh_token_required", "refresh_token is required");
|
|
2599
|
+
}
|
|
2600
|
+
const session = await refreshDeviceSession(refreshToken, paths);
|
|
2601
|
+
ctx.body = {
|
|
2602
|
+
ok: true,
|
|
2603
|
+
device: session.device,
|
|
2604
|
+
access_token: {
|
|
2605
|
+
token: session.accessToken.token,
|
|
2606
|
+
expires_at: session.accessToken.expiresAt
|
|
2607
|
+
},
|
|
2608
|
+
refresh_token: {
|
|
2609
|
+
token: session.refreshToken.token,
|
|
2610
|
+
expires_at: session.refreshToken.expiresAt
|
|
2611
|
+
}
|
|
2612
|
+
};
|
|
2613
|
+
});
|
|
2614
|
+
router.post("/api/v1/auth/logout", async (ctx) => {
|
|
2615
|
+
const body = await readJsonBody(ctx.req);
|
|
2616
|
+
const refreshToken = readString3(body, "refresh_token") ?? readString3(body, "refreshToken");
|
|
2617
|
+
if (refreshToken) {
|
|
2618
|
+
await revokeDeviceRefreshToken(refreshToken, paths);
|
|
2619
|
+
}
|
|
2620
|
+
ctx.body = { ok: true };
|
|
2621
|
+
});
|
|
2622
|
+
router.get("/api/v1/status", async (ctx) => {
|
|
2623
|
+
await authenticateRequest(ctx, paths);
|
|
2624
|
+
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
2625
|
+
ctx.body = {
|
|
2626
|
+
ok: true,
|
|
2627
|
+
version: LINK_VERSION,
|
|
2628
|
+
paired: Boolean(identity?.link_id),
|
|
2629
|
+
link_id: identity?.link_id ?? null,
|
|
2630
|
+
port: config.port
|
|
2631
|
+
};
|
|
2632
|
+
});
|
|
2633
|
+
router.get("/api/v1/logs", async (ctx) => {
|
|
2634
|
+
await authenticateRequest(ctx, paths);
|
|
2635
|
+
ctx.set("cache-control", "no-store");
|
|
2636
|
+
ctx.body = {
|
|
2637
|
+
ok: true,
|
|
2638
|
+
logs: await readRecentLogEntries({
|
|
2639
|
+
paths,
|
|
2640
|
+
limit: readLimit(ctx.query.limit)
|
|
2641
|
+
})
|
|
2642
|
+
};
|
|
2643
|
+
});
|
|
2644
|
+
router.get("/api/v1/models", async (ctx) => {
|
|
2645
|
+
await authenticateRequest(ctx, paths);
|
|
2646
|
+
ctx.body = await listHermesModels();
|
|
2647
|
+
});
|
|
2648
|
+
router.get("/api/v1/conversations", async (ctx) => {
|
|
2649
|
+
await authenticateRequest(ctx, paths);
|
|
2650
|
+
ctx.set("cache-control", "no-store");
|
|
2651
|
+
ctx.body = {
|
|
2652
|
+
ok: true,
|
|
2653
|
+
conversations: await conversations.listConversations()
|
|
2654
|
+
};
|
|
2655
|
+
});
|
|
2656
|
+
router.post("/api/v1/conversations", async (ctx) => {
|
|
2657
|
+
await authenticateRequest(ctx, paths);
|
|
2658
|
+
const body = await readJsonBody(ctx.req);
|
|
2659
|
+
ctx.status = 201;
|
|
2660
|
+
ctx.body = {
|
|
2661
|
+
ok: true,
|
|
2662
|
+
conversation: await conversations.createConversation({
|
|
2663
|
+
title: readString3(body, "title") ?? void 0
|
|
2664
|
+
})
|
|
2665
|
+
};
|
|
2666
|
+
});
|
|
2667
|
+
router.get("/api/v1/conversations/:conversationId/messages", async (ctx) => {
|
|
2668
|
+
await authenticateRequest(ctx, paths);
|
|
2669
|
+
ctx.set("cache-control", "no-store");
|
|
2670
|
+
const result = await conversations.getMessages(ctx.params.conversationId);
|
|
2671
|
+
ctx.body = {
|
|
2672
|
+
ok: true,
|
|
2673
|
+
conversation_id: ctx.params.conversationId,
|
|
2674
|
+
...result
|
|
2675
|
+
};
|
|
2676
|
+
});
|
|
2677
|
+
router.get("/api/v1/conversations/:conversationId/events", async (ctx) => {
|
|
2678
|
+
await authenticateRequest(ctx, paths);
|
|
2679
|
+
const after = readInteger(ctx.query.after) ?? 0;
|
|
2680
|
+
const history = await conversations.listEvents(ctx.params.conversationId, after);
|
|
2681
|
+
ctx.respond = false;
|
|
2682
|
+
const response = ctx.res;
|
|
2683
|
+
response.statusCode = 200;
|
|
2684
|
+
response.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
2685
|
+
response.setHeader("cache-control", "no-store");
|
|
2686
|
+
response.setHeader("connection", "keep-alive");
|
|
2687
|
+
for (const event of history) {
|
|
2688
|
+
writeSseEvent(response, event);
|
|
2689
|
+
}
|
|
2690
|
+
const unsubscribe = conversations.subscribe(ctx.params.conversationId, (event) => {
|
|
2691
|
+
writeSseEvent(response, event);
|
|
2692
|
+
});
|
|
2693
|
+
const cleanup = () => {
|
|
2694
|
+
unsubscribe();
|
|
2695
|
+
response.end();
|
|
2696
|
+
};
|
|
2697
|
+
ctx.req.on("close", cleanup);
|
|
2698
|
+
});
|
|
2699
|
+
router.post("/api/v1/conversations/:conversationId/messages", async (ctx) => {
|
|
2700
|
+
await authenticateRequest(ctx, paths);
|
|
2701
|
+
const body = await readJsonBody(ctx.req);
|
|
2702
|
+
const content = readString3(body, "content") ?? readString3(body, "text") ?? readString3(body, "input") ?? "";
|
|
2703
|
+
const attachments = readMessageAttachments(body.attachments ?? body.blobs);
|
|
2704
|
+
if (!content && attachments.length === 0) {
|
|
2705
|
+
throw new LinkHttpError(400, "message_content_required", "message content is required");
|
|
2706
|
+
}
|
|
2707
|
+
ctx.status = 202;
|
|
2708
|
+
ctx.body = {
|
|
2709
|
+
ok: true,
|
|
2710
|
+
...await conversations.sendMessage({
|
|
2711
|
+
conversationId: ctx.params.conversationId,
|
|
2712
|
+
content,
|
|
2713
|
+
attachments,
|
|
2714
|
+
clientMessageId: readString3(body, "client_message_id") ?? readString3(body, "clientMessageId") ?? void 0,
|
|
2715
|
+
idempotencyKey: readHeader(ctx, "idempotency-key") ?? void 0
|
|
2716
|
+
})
|
|
2717
|
+
};
|
|
2718
|
+
});
|
|
2719
|
+
router.post("/api/v1/conversations/:conversationId/ack", async (ctx) => {
|
|
2720
|
+
await authenticateRequest(ctx, paths);
|
|
2721
|
+
ctx.body = { ok: true };
|
|
2722
|
+
});
|
|
2723
|
+
router.delete("/api/v1/conversations/:conversationId", async (ctx) => {
|
|
2724
|
+
await authenticateRequest(ctx, paths);
|
|
2725
|
+
ctx.body = {
|
|
2726
|
+
ok: true,
|
|
2727
|
+
...await conversations.deleteConversation(ctx.params.conversationId),
|
|
2728
|
+
blob_gc_completed: true
|
|
2729
|
+
};
|
|
2730
|
+
});
|
|
2731
|
+
router.post("/api/v1/conversations/:conversationId/blobs", async (ctx) => {
|
|
2732
|
+
await authenticateRequest(ctx, paths);
|
|
2733
|
+
const bytes = await readRawBody(ctx.req, MAX_BLOB_UPLOAD_BYTES);
|
|
2734
|
+
if (bytes.byteLength === 0) {
|
|
2735
|
+
throw new LinkHttpError(400, "blob_empty", "Blob body is empty");
|
|
2736
|
+
}
|
|
2737
|
+
const blob = await conversations.writeBlob(ctx.params.conversationId, {
|
|
2738
|
+
bytes,
|
|
2739
|
+
filename: readHeader(ctx, "x-filename") ?? void 0,
|
|
2740
|
+
mime: ctx.get("content-type") || void 0
|
|
2741
|
+
});
|
|
2742
|
+
ctx.status = 201;
|
|
2743
|
+
ctx.body = { ok: true, blob };
|
|
2744
|
+
});
|
|
2745
|
+
router.get("/api/v1/conversations/:conversationId/blobs/:blobId", async (ctx) => {
|
|
2746
|
+
await authenticateRequest(ctx, paths);
|
|
2747
|
+
const blob = await conversations.readBlob(ctx.params.conversationId, ctx.params.blobId);
|
|
2748
|
+
ctx.set("content-type", blob.mime);
|
|
2749
|
+
ctx.set("content-length", String(blob.size));
|
|
2750
|
+
ctx.set("cache-control", "private, max-age=86400");
|
|
2751
|
+
ctx.set("content-disposition", `inline; filename="${blob.filename.replaceAll('"', "")}"`);
|
|
2752
|
+
ctx.body = blob.bytes;
|
|
2753
|
+
});
|
|
2754
|
+
router.post("/api/v1/runs", async (ctx) => {
|
|
2755
|
+
await authenticateRequest(ctx, paths);
|
|
2756
|
+
const body = await readJsonBody(ctx.req);
|
|
2757
|
+
const input = readString3(body, "input");
|
|
2758
|
+
if (!input) {
|
|
2759
|
+
throw new LinkHttpError(400, "run_input_required", "input is required");
|
|
2760
|
+
}
|
|
2761
|
+
ctx.status = 202;
|
|
2762
|
+
ctx.body = await createHermesRun({
|
|
2763
|
+
input,
|
|
2764
|
+
conversation_history: readConversationHistory(body.conversation_history ?? body.conversationHistory),
|
|
2765
|
+
session_id: readString3(body, "session_id") ?? readString3(body, "sessionId") ?? void 0
|
|
2766
|
+
});
|
|
2767
|
+
});
|
|
2768
|
+
router.get("/api/v1/runs/:runId/events", async (ctx) => {
|
|
2769
|
+
await authenticateRequest(ctx, paths);
|
|
2770
|
+
const response = await streamHermesRunEvents(ctx.params.runId);
|
|
2771
|
+
ctx.status = response.status;
|
|
2772
|
+
for (const [key, value] of response.headers.entries()) {
|
|
2773
|
+
ctx.set(key, value);
|
|
2774
|
+
}
|
|
2775
|
+
ctx.respond = false;
|
|
2776
|
+
const nodeResponse = ctx.res;
|
|
2777
|
+
nodeResponse.statusCode = response.status;
|
|
2778
|
+
for (const [key, value] of response.headers.entries()) {
|
|
2779
|
+
nodeResponse.setHeader(key, value);
|
|
2780
|
+
}
|
|
2781
|
+
if (response.body) {
|
|
2782
|
+
Readable.fromWeb(response.body).pipe(nodeResponse);
|
|
2783
|
+
} else {
|
|
2784
|
+
nodeResponse.end();
|
|
2785
|
+
}
|
|
2786
|
+
});
|
|
2787
|
+
router.post("/api/v1/runs/:runId/cancel", async (ctx) => {
|
|
2788
|
+
await authenticateRequest(ctx, paths);
|
|
2789
|
+
await cancelHermesRun(ctx.params.runId);
|
|
2790
|
+
ctx.body = { ok: true };
|
|
2791
|
+
});
|
|
2792
|
+
router.get("/api/v1/profiles", async (ctx) => {
|
|
2793
|
+
await authenticateRequest(ctx, paths);
|
|
2794
|
+
ctx.set("cache-control", "no-store");
|
|
2795
|
+
ctx.body = {
|
|
2796
|
+
ok: true,
|
|
2797
|
+
profiles: await listHermesProfiles()
|
|
2798
|
+
};
|
|
2799
|
+
});
|
|
2800
|
+
router.get("/api/v1/profiles/:name/status", async (ctx) => {
|
|
2801
|
+
await authenticateRequest(ctx, paths);
|
|
2802
|
+
ctx.set("cache-control", "no-store");
|
|
2803
|
+
ctx.body = {
|
|
2804
|
+
ok: true,
|
|
2805
|
+
profile: await getHermesProfileStatus(ctx.params.name)
|
|
2806
|
+
};
|
|
2807
|
+
});
|
|
2808
|
+
router.post("/api/v1/profiles", async (ctx) => {
|
|
2809
|
+
await authenticateRequest(ctx, paths);
|
|
2810
|
+
const body = await readJsonBody(ctx.req);
|
|
2811
|
+
const name = readProfileName(body);
|
|
2812
|
+
ctx.status = 201;
|
|
2813
|
+
ctx.body = {
|
|
2814
|
+
ok: true,
|
|
2815
|
+
profile: await createHermesProfile(name)
|
|
2816
|
+
};
|
|
2817
|
+
});
|
|
2818
|
+
router.post("/api/v1/profiles/:name/use", async (ctx) => {
|
|
2819
|
+
await authenticateRequest(ctx, paths);
|
|
2820
|
+
ctx.body = {
|
|
2821
|
+
ok: true,
|
|
2822
|
+
profile: await useHermesProfile(ctx.params.name)
|
|
2823
|
+
};
|
|
2824
|
+
});
|
|
2825
|
+
router.patch("/api/v1/profiles/:name", async (ctx) => {
|
|
2826
|
+
await authenticateRequest(ctx, paths);
|
|
2827
|
+
const body = await readJsonBody(ctx.req);
|
|
2828
|
+
const name = readProfileName(body);
|
|
2829
|
+
ctx.body = {
|
|
2830
|
+
ok: true,
|
|
2831
|
+
profile: await renameHermesProfile(ctx.params.name, name)
|
|
2832
|
+
};
|
|
2833
|
+
});
|
|
2834
|
+
router.delete("/api/v1/profiles/:name", async (ctx) => {
|
|
2835
|
+
await authenticateRequest(ctx, paths);
|
|
2836
|
+
await deleteHermesProfile(ctx.params.name);
|
|
2837
|
+
ctx.status = 204;
|
|
2838
|
+
});
|
|
2839
|
+
app.use(router.routes());
|
|
2840
|
+
app.use(router.allowedMethods());
|
|
2841
|
+
return app;
|
|
2842
|
+
}
|
|
2843
|
+
async function readJsonBody(request) {
|
|
2844
|
+
const raw = await readRawBody(request, MAX_JSON_BODY_BYTES);
|
|
2845
|
+
if (raw.byteLength === 0) {
|
|
2846
|
+
return {};
|
|
2847
|
+
}
|
|
2848
|
+
try {
|
|
2849
|
+
return JSON.parse(Buffer.from(raw).toString("utf8"));
|
|
2850
|
+
} catch {
|
|
2851
|
+
throw new LinkHttpError(400, "invalid_json", "Request body must be valid JSON");
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
async function readRawBody(request, maxBytes) {
|
|
2855
|
+
const chunks = [];
|
|
2856
|
+
let totalBytes = 0;
|
|
2857
|
+
for await (const chunk of request) {
|
|
2858
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2859
|
+
totalBytes += buffer.byteLength;
|
|
2860
|
+
if (totalBytes > maxBytes) {
|
|
2861
|
+
throw new LinkHttpError(413, "request_body_too_large", "Request body is too large");
|
|
2862
|
+
}
|
|
2863
|
+
chunks.push(buffer);
|
|
2864
|
+
}
|
|
2865
|
+
return Buffer.concat(chunks);
|
|
2866
|
+
}
|
|
2867
|
+
function readProfileName(body) {
|
|
2868
|
+
if (typeof body.name !== "string") {
|
|
2869
|
+
throw new Error("invalid profile name");
|
|
2870
|
+
}
|
|
2871
|
+
return body.name;
|
|
2872
|
+
}
|
|
2873
|
+
async function authenticateRequest(ctx, paths) {
|
|
2874
|
+
const token = readBearerToken(ctx.get("authorization"));
|
|
2875
|
+
if (!token) {
|
|
2876
|
+
throw new LinkHttpError(401, "auth_required", "Authorization bearer token is required");
|
|
2877
|
+
}
|
|
2878
|
+
const device = await authenticateDeviceAccessToken(token, paths);
|
|
2879
|
+
if (device) {
|
|
2880
|
+
return { kind: "device", device };
|
|
2881
|
+
}
|
|
2882
|
+
const [identity, config] = await Promise.all([loadRequiredIdentity2(paths), loadConfig(paths)]);
|
|
2883
|
+
const claims = await verifyAppConnectToken(token, {
|
|
2884
|
+
config,
|
|
2885
|
+
linkId: identity.link_id
|
|
2886
|
+
});
|
|
2887
|
+
return { kind: "app-connect", accountId: claims.sub };
|
|
2888
|
+
}
|
|
2889
|
+
async function loadRequiredIdentity2(paths) {
|
|
2890
|
+
const identity = await loadIdentity(paths);
|
|
2891
|
+
if (!identity?.link_id) {
|
|
2892
|
+
throw new LinkHttpError(409, "link_not_paired", "Hermes Link is not paired");
|
|
2893
|
+
}
|
|
2894
|
+
return identity;
|
|
2895
|
+
}
|
|
2896
|
+
function readBearerToken(value) {
|
|
2897
|
+
const trimmed = value.trim();
|
|
2898
|
+
if (!trimmed.toLowerCase().startsWith("bearer ")) {
|
|
2899
|
+
return null;
|
|
2900
|
+
}
|
|
2901
|
+
const token = trimmed.slice(7).trim();
|
|
2902
|
+
return token || null;
|
|
2903
|
+
}
|
|
2904
|
+
function readString3(body, key) {
|
|
2905
|
+
const value = body[key];
|
|
2906
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
2907
|
+
}
|
|
2908
|
+
function readLimit(value) {
|
|
2909
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
2910
|
+
if (typeof raw !== "string") {
|
|
2911
|
+
return void 0;
|
|
2912
|
+
}
|
|
2913
|
+
const parsed = Number.parseInt(raw, 10);
|
|
2914
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2915
|
+
}
|
|
2916
|
+
function readInteger(value) {
|
|
2917
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
2918
|
+
if (typeof raw !== "string") {
|
|
2919
|
+
return void 0;
|
|
2920
|
+
}
|
|
2921
|
+
const parsed = Number.parseInt(raw, 10);
|
|
2922
|
+
return Number.isFinite(parsed) ? Math.max(0, parsed) : void 0;
|
|
2923
|
+
}
|
|
2924
|
+
function readHeader(ctx, name) {
|
|
2925
|
+
const value = ctx.get(name).trim();
|
|
2926
|
+
return value ? value : null;
|
|
2927
|
+
}
|
|
2928
|
+
function writeSseEvent(response, event) {
|
|
2929
|
+
response.write(`event: ${event.type}
|
|
2930
|
+
`);
|
|
2931
|
+
response.write(`data: ${JSON.stringify(event)}
|
|
2932
|
+
|
|
2933
|
+
`);
|
|
2934
|
+
}
|
|
2935
|
+
function readConversationHistory(value) {
|
|
2936
|
+
if (!Array.isArray(value)) {
|
|
2937
|
+
return [];
|
|
2938
|
+
}
|
|
2939
|
+
return value.map((item) => {
|
|
2940
|
+
if (typeof item !== "object" || item === null) {
|
|
2941
|
+
return null;
|
|
2942
|
+
}
|
|
2943
|
+
const role = item.role;
|
|
2944
|
+
const content = item.content;
|
|
2945
|
+
if (typeof role !== "string" || typeof content !== "string") {
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
return { role, content };
|
|
2949
|
+
}).filter((item) => Boolean(item));
|
|
2950
|
+
}
|
|
2951
|
+
function readMessageAttachments(value) {
|
|
2952
|
+
if (!Array.isArray(value)) {
|
|
2953
|
+
return [];
|
|
2954
|
+
}
|
|
2955
|
+
return value.map((item) => {
|
|
2956
|
+
if (typeof item === "string" && item.trim()) {
|
|
2957
|
+
return { blob_id: item.trim() };
|
|
2958
|
+
}
|
|
2959
|
+
if (typeof item !== "object" || item === null) {
|
|
2960
|
+
return null;
|
|
2961
|
+
}
|
|
2962
|
+
const record = item;
|
|
2963
|
+
const blobId = typeof record.blob_id === "string" ? record.blob_id.trim() : typeof record.blobId === "string" ? record.blobId.trim() : "";
|
|
2964
|
+
return blobId ? { blob_id: blobId } : null;
|
|
2965
|
+
}).filter((item) => Boolean(item));
|
|
2966
|
+
}
|
|
2967
|
+
function routeObjects(urls) {
|
|
2968
|
+
return urls.map((url) => ({
|
|
2969
|
+
kind: url.includes("/api/v1/relay/links/") ? "relay" : "lan",
|
|
2970
|
+
url,
|
|
2971
|
+
observed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2972
|
+
}));
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
export {
|
|
2976
|
+
LINK_VERSION,
|
|
2977
|
+
LINK_COMMAND,
|
|
2978
|
+
resolveRuntimePaths,
|
|
2979
|
+
loadConfig,
|
|
2980
|
+
ensureHermesApiServerConfig,
|
|
2981
|
+
ensureHermesApiServerAvailable,
|
|
2982
|
+
loadIdentity,
|
|
2983
|
+
ensureIdentity,
|
|
2984
|
+
getIdentityStatus,
|
|
2985
|
+
preparePairing,
|
|
2986
|
+
readPairingClaim,
|
|
2987
|
+
clearPairingClaim,
|
|
2988
|
+
createFileLogger,
|
|
2989
|
+
getLinkLogFile,
|
|
2990
|
+
createApp
|
|
2991
|
+
};
|
|
2992
|
+
//# sourceMappingURL=chunk-SCIAZZ4C.js.map
|