@hermespilot/link 0.1.5 → 0.1.6
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.
|
@@ -4,7 +4,7 @@ import Router from "@koa/router";
|
|
|
4
4
|
import { Readable } from "stream";
|
|
5
5
|
|
|
6
6
|
// src/constants.ts
|
|
7
|
-
var LINK_VERSION = "0.1.
|
|
7
|
+
var LINK_VERSION = "0.1.6";
|
|
8
8
|
var LINK_COMMAND = "hermeslink";
|
|
9
9
|
var LINK_DEFAULT_PORT = 52379;
|
|
10
10
|
var LINK_RUNTIME_DIR_NAME = ".hermeslink";
|
|
@@ -23,6 +23,9 @@ function resolveRuntimePaths(homeDir = resolveRuntimeHome()) {
|
|
|
23
23
|
stateFile: path.join(homeDir, "state.json"),
|
|
24
24
|
credentialsFile: path.join(homeDir, "credentials.json"),
|
|
25
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"),
|
|
26
29
|
logsDir: path.join(homeDir, "logs"),
|
|
27
30
|
runDir: path.join(homeDir, "run"),
|
|
28
31
|
pairingDir: path.join(homeDir, "pairing")
|
|
@@ -91,19 +94,11 @@ function normalizeConfiguredLanguage(language) {
|
|
|
91
94
|
return defaultLinkConfig.language;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
// src/
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.code = code;
|
|
100
|
-
}
|
|
101
|
-
status;
|
|
102
|
-
code;
|
|
103
|
-
};
|
|
104
|
-
function isLinkHttpError(error) {
|
|
105
|
-
return error instanceof LinkHttpError;
|
|
106
|
-
}
|
|
97
|
+
// src/conversations/conversation-service.ts
|
|
98
|
+
import { EventEmitter } from "events";
|
|
99
|
+
import { appendFile, mkdir as mkdir3, readdir, readFile as readFile3, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
100
|
+
import path4 from "path";
|
|
101
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
107
102
|
|
|
108
103
|
// src/hermes/api-server.ts
|
|
109
104
|
import { randomUUID } from "crypto";
|
|
@@ -205,6 +200,20 @@ function isNodeError2(error, code) {
|
|
|
205
200
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
206
201
|
}
|
|
207
202
|
|
|
203
|
+
// src/http/errors.ts
|
|
204
|
+
var LinkHttpError = class extends Error {
|
|
205
|
+
constructor(status, code, message) {
|
|
206
|
+
super(message);
|
|
207
|
+
this.status = status;
|
|
208
|
+
this.code = code;
|
|
209
|
+
}
|
|
210
|
+
status;
|
|
211
|
+
code;
|
|
212
|
+
};
|
|
213
|
+
function isLinkHttpError(error) {
|
|
214
|
+
return error instanceof LinkHttpError;
|
|
215
|
+
}
|
|
216
|
+
|
|
208
217
|
// src/hermes/api-server.ts
|
|
209
218
|
var fallbackRuns = /* @__PURE__ */ new Map();
|
|
210
219
|
async function listHermesModels(options = {}) {
|
|
@@ -270,7 +279,7 @@ async function cancelHermesRun(runId, options = {}) {
|
|
|
270
279
|
}
|
|
271
280
|
fallbackRuns.delete(runId);
|
|
272
281
|
}
|
|
273
|
-
async function callHermesApi(
|
|
282
|
+
async function callHermesApi(path8, init, options) {
|
|
274
283
|
const config = await readHermesApiServerConfig();
|
|
275
284
|
if (!config.port || !config.key) {
|
|
276
285
|
return new Response(null, { status: 503 });
|
|
@@ -280,7 +289,7 @@ async function callHermesApi(path7, init, options) {
|
|
|
280
289
|
headers.set("accept", headers.get("accept") ?? "application/json");
|
|
281
290
|
headers.set("x-api-key", config.key);
|
|
282
291
|
headers.set("authorization", `Bearer ${config.key}`);
|
|
283
|
-
return await fetcher(`http://127.0.0.1:${config.port}${
|
|
292
|
+
return await fetcher(`http://127.0.0.1:${config.port}${path8}`, {
|
|
284
293
|
...init,
|
|
285
294
|
headers
|
|
286
295
|
}).catch(() => new Response(null, { status: 503 }));
|
|
@@ -314,19 +323,991 @@ function readString(payload, key) {
|
|
|
314
323
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
315
324
|
}
|
|
316
325
|
|
|
326
|
+
// src/hermes/cli.ts
|
|
327
|
+
import { execFile } from "child_process";
|
|
328
|
+
import { promisify } from "util";
|
|
329
|
+
var execFileAsync = promisify(execFile);
|
|
330
|
+
async function deleteHermesSession(sessionId) {
|
|
331
|
+
if (!sessionId.trim()) {
|
|
332
|
+
throw new LinkHttpError(400, "hermes_session_id_required", "Hermes session id is required");
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
await execFileAsync(hermesBin(), ["sessions", "delete", sessionId, "--yes"], {
|
|
336
|
+
timeout: 1e4,
|
|
337
|
+
windowsHide: true
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
throw new LinkHttpError(
|
|
341
|
+
502,
|
|
342
|
+
"hermes_session_delete_failed",
|
|
343
|
+
error instanceof Error ? error.message : "Hermes session delete failed"
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function hermesBin() {
|
|
348
|
+
return process.env.HERMES_BIN?.trim() || "hermes";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/conversations/conversation-service.ts
|
|
352
|
+
var MAX_IMPORTED_BLOB_BYTES = 100 * 1024 * 1024;
|
|
353
|
+
var MAX_UPLOADED_BLOB_BYTES = 50 * 1024 * 1024;
|
|
354
|
+
var CONVERSATION_ID_PATTERN = /^conv_[a-f0-9]{32}$/u;
|
|
355
|
+
var BLOB_ID_PATTERN = /^blob_[a-f0-9]{32}$/u;
|
|
356
|
+
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;
|
|
357
|
+
var ConversationService = class {
|
|
358
|
+
constructor(paths, logger) {
|
|
359
|
+
this.paths = paths;
|
|
360
|
+
this.logger = logger;
|
|
361
|
+
this.emitter.setMaxListeners(0);
|
|
362
|
+
}
|
|
363
|
+
paths;
|
|
364
|
+
logger;
|
|
365
|
+
emitter = new EventEmitter();
|
|
366
|
+
async listConversations() {
|
|
367
|
+
await mkdir3(this.paths.conversationsDir, { recursive: true, mode: 448 });
|
|
368
|
+
const entries = await readdir(this.paths.conversationsDir, { withFileTypes: true }).catch((error) => {
|
|
369
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
throw error;
|
|
373
|
+
});
|
|
374
|
+
const summaries = [];
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
if (!entry.isDirectory()) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const manifest = await this.readManifest(entry.name).catch(() => null);
|
|
380
|
+
if (!manifest || manifest.status !== "active") {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const snapshot = await this.readSnapshot(entry.name).catch(() => emptySnapshot());
|
|
384
|
+
summaries.push(toSummary(manifest, snapshot));
|
|
385
|
+
}
|
|
386
|
+
return summaries.sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at));
|
|
387
|
+
}
|
|
388
|
+
async createConversation(input = {}) {
|
|
389
|
+
await mkdir3(this.paths.conversationsDir, { recursive: true, mode: 448 });
|
|
390
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
391
|
+
const id = `conv_${randomUUID2().replaceAll("-", "")}`;
|
|
392
|
+
const title = input.title?.trim() || "Untitled";
|
|
393
|
+
const manifest = {
|
|
394
|
+
id,
|
|
395
|
+
schema_version: 1,
|
|
396
|
+
kind: "direct",
|
|
397
|
+
title,
|
|
398
|
+
status: "active",
|
|
399
|
+
hermes_session_id: `hp_${id}`,
|
|
400
|
+
created_at: now,
|
|
401
|
+
updated_at: now,
|
|
402
|
+
last_event_seq: 0
|
|
403
|
+
};
|
|
404
|
+
await mkdir3(this.conversationDir(id), { recursive: true, mode: 448 });
|
|
405
|
+
await this.writeManifest(manifest);
|
|
406
|
+
await this.writeSnapshot(id, emptySnapshot());
|
|
407
|
+
const event = await this.appendEvent(manifest.id, {
|
|
408
|
+
type: "conversation.created",
|
|
409
|
+
payload: { title }
|
|
410
|
+
});
|
|
411
|
+
return { ...toSummary({ ...manifest, last_event_seq: event.seq }, emptySnapshot()), last_event_seq: event.seq };
|
|
412
|
+
}
|
|
413
|
+
async getMessages(conversationId) {
|
|
414
|
+
const manifest = await this.readActiveManifest(conversationId);
|
|
415
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
416
|
+
return {
|
|
417
|
+
messages: snapshot.messages,
|
|
418
|
+
last_event_seq: manifest.last_event_seq
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
async listEvents(conversationId, after = 0) {
|
|
422
|
+
await this.readManifest(conversationId);
|
|
423
|
+
const eventsPath = this.eventsPath(conversationId);
|
|
424
|
+
const raw = await readFile3(eventsPath, "utf8").catch((error) => {
|
|
425
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
426
|
+
return "";
|
|
427
|
+
}
|
|
428
|
+
throw error;
|
|
429
|
+
});
|
|
430
|
+
return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((event) => event.seq > after);
|
|
431
|
+
}
|
|
432
|
+
subscribe(conversationId, listener) {
|
|
433
|
+
const eventName = this.liveEventName(conversationId);
|
|
434
|
+
this.emitter.on(eventName, listener);
|
|
435
|
+
return () => this.emitter.off(eventName, listener);
|
|
436
|
+
}
|
|
437
|
+
async sendMessage(input) {
|
|
438
|
+
const manifest = await this.readActiveManifest(input.conversationId);
|
|
439
|
+
const content = input.content.trim();
|
|
440
|
+
const userAttachmentParts = await this.resolveMessageAttachmentParts(
|
|
441
|
+
manifest.id,
|
|
442
|
+
input.attachments ?? []
|
|
443
|
+
);
|
|
444
|
+
if (!content && userAttachmentParts.length === 0) {
|
|
445
|
+
throw new LinkHttpError(400, "message_content_required", "message content is required");
|
|
446
|
+
}
|
|
447
|
+
const idempotencyKey = input.clientMessageId ?? input.idempotencyKey;
|
|
448
|
+
const snapshot = await this.readSnapshot(input.conversationId);
|
|
449
|
+
if (idempotencyKey) {
|
|
450
|
+
const existingUser = snapshot.messages.find((message) => message.client_message_id === idempotencyKey);
|
|
451
|
+
if (existingUser) {
|
|
452
|
+
const existingRun = snapshot.runs.find((run2) => run2.trigger_message_id === existingUser.id);
|
|
453
|
+
const existingAssistant = existingRun ? snapshot.messages.find((message) => message.id === existingRun.assistant_message_id) : null;
|
|
454
|
+
return {
|
|
455
|
+
conversation_id: manifest.id,
|
|
456
|
+
user_message: { id: existingUser.id, status: existingUser.status },
|
|
457
|
+
assistant_message: { id: existingAssistant?.id ?? "", status: existingAssistant?.status ?? "failed" },
|
|
458
|
+
run: { id: existingRun?.id ?? "", status: existingRun?.status ?? "unknown" },
|
|
459
|
+
last_event_seq: manifest.last_event_seq
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
464
|
+
const userMessage = {
|
|
465
|
+
id: `msg_${randomUUID2().replaceAll("-", "")}`,
|
|
466
|
+
schema_version: 1,
|
|
467
|
+
conversation_id: manifest.id,
|
|
468
|
+
role: "user",
|
|
469
|
+
status: "completed",
|
|
470
|
+
client_message_id: idempotencyKey,
|
|
471
|
+
created_at: now,
|
|
472
|
+
updated_at: now,
|
|
473
|
+
sender: { id: "app_user", type: "human", display_name: "Me" },
|
|
474
|
+
parts: [{ type: "text", text: content }, ...userAttachmentParts],
|
|
475
|
+
attachments: userAttachmentParts.map((part) => ({
|
|
476
|
+
blob_id: part.blob,
|
|
477
|
+
mime: part.mime,
|
|
478
|
+
size: part.size,
|
|
479
|
+
filename: part.filename
|
|
480
|
+
})),
|
|
481
|
+
raw: { format: "hermes-link-user-message", payload: { content, attachments: input.attachments ?? [] } }
|
|
482
|
+
};
|
|
483
|
+
const assistantMessage = {
|
|
484
|
+
id: `msg_${randomUUID2().replaceAll("-", "")}`,
|
|
485
|
+
schema_version: 1,
|
|
486
|
+
conversation_id: manifest.id,
|
|
487
|
+
role: "assistant",
|
|
488
|
+
status: "streaming",
|
|
489
|
+
created_at: now,
|
|
490
|
+
updated_at: now,
|
|
491
|
+
sender: { id: "agent_default", type: "agent", display_name: "Hermes", profile: "default" },
|
|
492
|
+
parts: [{ type: "text", text: "" }],
|
|
493
|
+
attachments: []
|
|
494
|
+
};
|
|
495
|
+
const run = {
|
|
496
|
+
id: `run_${randomUUID2().replaceAll("-", "")}`,
|
|
497
|
+
conversation_id: manifest.id,
|
|
498
|
+
trigger_message_id: userMessage.id,
|
|
499
|
+
assistant_message_id: assistantMessage.id,
|
|
500
|
+
hermes_session_id: manifest.hermes_session_id,
|
|
501
|
+
status: "running",
|
|
502
|
+
started_at: now
|
|
503
|
+
};
|
|
504
|
+
snapshot.messages.push(userMessage, assistantMessage);
|
|
505
|
+
snapshot.runs.push(run);
|
|
506
|
+
await this.writeSnapshot(manifest.id, snapshot);
|
|
507
|
+
await this.appendEvent(manifest.id, {
|
|
508
|
+
type: "message.created",
|
|
509
|
+
message_id: userMessage.id,
|
|
510
|
+
payload: { message: userMessage }
|
|
511
|
+
});
|
|
512
|
+
await this.appendEvent(manifest.id, {
|
|
513
|
+
type: "message.created",
|
|
514
|
+
message_id: assistantMessage.id,
|
|
515
|
+
run_id: run.id,
|
|
516
|
+
payload: { message: assistantMessage }
|
|
517
|
+
});
|
|
518
|
+
const runEvent = await this.appendEvent(manifest.id, {
|
|
519
|
+
type: "run.started",
|
|
520
|
+
message_id: assistantMessage.id,
|
|
521
|
+
run_id: run.id,
|
|
522
|
+
payload: { run }
|
|
523
|
+
});
|
|
524
|
+
void this.startRunWorker(manifest.id, run.id, this.buildHermesInput(content, userAttachmentParts)).catch((error) => {
|
|
525
|
+
void this.failRun(manifest.id, run.id, error instanceof Error ? error.message : String(error));
|
|
526
|
+
});
|
|
527
|
+
return {
|
|
528
|
+
conversation_id: manifest.id,
|
|
529
|
+
user_message: { id: userMessage.id, status: userMessage.status },
|
|
530
|
+
assistant_message: { id: assistantMessage.id, status: assistantMessage.status },
|
|
531
|
+
run: { id: run.id, status: run.status },
|
|
532
|
+
last_event_seq: runEvent.seq
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
async deleteConversation(conversationId) {
|
|
536
|
+
const manifest = await this.readActiveManifest(conversationId);
|
|
537
|
+
const snapshot = await this.readSnapshot(conversationId).catch(() => emptySnapshot());
|
|
538
|
+
const referencedBlobIds = /* @__PURE__ */ new Set([
|
|
539
|
+
...collectBlobIds(snapshot),
|
|
540
|
+
...await this.listConversationBlobIds(conversationId)
|
|
541
|
+
]);
|
|
542
|
+
await deleteHermesSession(manifest.hermes_session_id);
|
|
543
|
+
const deletedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
544
|
+
const next = {
|
|
545
|
+
...manifest,
|
|
546
|
+
status: "deleted_soft",
|
|
547
|
+
updated_at: deletedAt,
|
|
548
|
+
deleted_at: deletedAt
|
|
549
|
+
};
|
|
550
|
+
await this.writeManifest(next);
|
|
551
|
+
const deleteEvent = await this.appendEvent(conversationId, {
|
|
552
|
+
type: "conversation.deleted",
|
|
553
|
+
payload: { deleted_at: deletedAt, hermes_session_id: manifest.hermes_session_id }
|
|
554
|
+
});
|
|
555
|
+
await this.writeSnapshot(conversationId, emptySnapshot());
|
|
556
|
+
await writeFile2(this.eventsPath(conversationId), `${JSON.stringify(deleteEvent)}
|
|
557
|
+
`, { mode: 384 });
|
|
558
|
+
await this.pruneConversationBlobReferences(conversationId, [...referencedBlobIds]);
|
|
559
|
+
return { conversation_id: conversationId, hermes_deleted: true, deleted_at: deletedAt };
|
|
560
|
+
}
|
|
561
|
+
async writeBlob(conversationId, input) {
|
|
562
|
+
await this.readActiveManifest(conversationId);
|
|
563
|
+
if (input.bytes.byteLength > MAX_UPLOADED_BLOB_BYTES) {
|
|
564
|
+
throw new LinkHttpError(413, "blob_too_large", "Blob is too large");
|
|
565
|
+
}
|
|
566
|
+
const id = `blob_${randomUUID2().replaceAll("-", "")}`;
|
|
567
|
+
const filePath = this.blobPath(id);
|
|
568
|
+
await mkdir3(path4.dirname(filePath), { recursive: true, mode: 448 });
|
|
569
|
+
await writeFile2(filePath, input.bytes, { mode: 384 });
|
|
570
|
+
const manifestPath = `${filePath}.json`;
|
|
571
|
+
const blob = {
|
|
572
|
+
id,
|
|
573
|
+
size: input.bytes.byteLength,
|
|
574
|
+
mime: input.mime || "application/octet-stream",
|
|
575
|
+
filename: sanitizeFilename(input.filename, id),
|
|
576
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
577
|
+
conversation_ids: [conversationId]
|
|
578
|
+
};
|
|
579
|
+
await writeJsonFile(manifestPath, blob);
|
|
580
|
+
await this.appendEvent(conversationId, {
|
|
581
|
+
type: "blob.created",
|
|
582
|
+
payload: blob
|
|
583
|
+
});
|
|
584
|
+
return blob;
|
|
585
|
+
}
|
|
586
|
+
async readBlob(conversationId, blobId) {
|
|
587
|
+
await this.readActiveManifest(conversationId);
|
|
588
|
+
const filePath = this.blobPath(blobId);
|
|
589
|
+
const manifest = await this.readBlobManifest(conversationId, blobId);
|
|
590
|
+
const bytes = await readFile3(filePath).catch((error) => {
|
|
591
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
592
|
+
throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
|
|
593
|
+
}
|
|
594
|
+
throw error;
|
|
595
|
+
});
|
|
596
|
+
return {
|
|
597
|
+
bytes,
|
|
598
|
+
mime: manifest.mime || "application/octet-stream",
|
|
599
|
+
filename: manifest.filename || blobId,
|
|
600
|
+
size: manifest.size || bytes.byteLength
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
async resolveMessageAttachmentParts(conversationId, attachments) {
|
|
604
|
+
const parts = [];
|
|
605
|
+
const seen = /* @__PURE__ */ new Set();
|
|
606
|
+
for (const attachment of attachments) {
|
|
607
|
+
const blobId = attachment.blob_id ?? attachment.blobId;
|
|
608
|
+
if (!blobId || seen.has(blobId)) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
seen.add(blobId);
|
|
612
|
+
const manifest = await this.readBlobManifest(conversationId, blobId);
|
|
613
|
+
const mime = manifest.mime || "application/octet-stream";
|
|
614
|
+
parts.push({
|
|
615
|
+
type: mediaKindForMime(mime),
|
|
616
|
+
blob: blobId,
|
|
617
|
+
mime,
|
|
618
|
+
size: manifest.size,
|
|
619
|
+
filename: manifest.filename || blobId,
|
|
620
|
+
url: `/api/v1/conversations/${encodeURIComponent(conversationId)}/blobs/${encodeURIComponent(blobId)}`
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return parts;
|
|
624
|
+
}
|
|
625
|
+
buildHermesInput(content, parts) {
|
|
626
|
+
const attachmentLines = parts.filter((part) => part.blob).map((part) => {
|
|
627
|
+
const label = part.filename ?? part.blob;
|
|
628
|
+
const mime = part.mime ? `, ${part.mime}` : "";
|
|
629
|
+
const size = part.size ? `, ${part.size} bytes` : "";
|
|
630
|
+
return `- ${label}${mime}${size}: ${this.blobPath(part.blob)}`;
|
|
631
|
+
});
|
|
632
|
+
if (attachmentLines.length === 0) {
|
|
633
|
+
return content;
|
|
634
|
+
}
|
|
635
|
+
const prefix = content ? `${content}
|
|
636
|
+
|
|
637
|
+
` : "";
|
|
638
|
+
return `${prefix}Attachments available on this computer:
|
|
639
|
+
${attachmentLines.join("\n")}`;
|
|
640
|
+
}
|
|
641
|
+
async readBlobManifest(conversationId, blobId) {
|
|
642
|
+
assertValidConversationId(conversationId);
|
|
643
|
+
assertValidBlobId(blobId);
|
|
644
|
+
const manifest = await readJsonFile(
|
|
645
|
+
`${this.blobPath(blobId)}.json`
|
|
646
|
+
);
|
|
647
|
+
if (!manifest?.conversation_ids?.includes(conversationId)) {
|
|
648
|
+
throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
|
|
649
|
+
}
|
|
650
|
+
return manifest;
|
|
651
|
+
}
|
|
652
|
+
async writeBlobFromFile(conversationId, source) {
|
|
653
|
+
const sourcePath = resolveMediaSourcePath(source.path);
|
|
654
|
+
const fileStat = await stat(sourcePath).catch((error) => {
|
|
655
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
656
|
+
throw new LinkHttpError(404, "media_source_not_found", "Hermes output file was not found");
|
|
657
|
+
}
|
|
658
|
+
throw error;
|
|
659
|
+
});
|
|
660
|
+
if (!fileStat.isFile()) {
|
|
661
|
+
throw new LinkHttpError(400, "media_source_not_file", "Hermes output media source is not a file");
|
|
662
|
+
}
|
|
663
|
+
if (fileStat.size > MAX_IMPORTED_BLOB_BYTES) {
|
|
664
|
+
throw new LinkHttpError(413, "media_source_too_large", "Hermes output media source is too large");
|
|
665
|
+
}
|
|
666
|
+
return this.writeBlob(conversationId, {
|
|
667
|
+
bytes: await readFile3(sourcePath),
|
|
668
|
+
filename: path4.basename(sourcePath),
|
|
669
|
+
mime: source.mime ?? inferMimeType(sourcePath)
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
async startRunWorker(conversationId, runId, input) {
|
|
673
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
674
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
675
|
+
if (!run) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const runResponse = await createHermesRun({
|
|
679
|
+
input,
|
|
680
|
+
session_id: run.hermes_session_id,
|
|
681
|
+
conversation_history: buildConversationHistory(snapshot, run)
|
|
682
|
+
});
|
|
683
|
+
await this.updateRun(conversationId, runId, { hermes_run_id: runResponse.run_id });
|
|
684
|
+
const response = await streamHermesRunEvents(runResponse.run_id);
|
|
685
|
+
for await (const event of parseSseResponse(response)) {
|
|
686
|
+
if (!await this.isConversationActive(conversationId)) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const type = event.payloadType;
|
|
690
|
+
if (type === "run.completed") {
|
|
691
|
+
await this.importMediaReferencesForEvent(conversationId, runId, event);
|
|
692
|
+
await this.completeRun(conversationId, runId, event);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (type === "run.failed") {
|
|
696
|
+
await this.importMediaReferencesForEvent(conversationId, runId, event);
|
|
697
|
+
await this.failRun(conversationId, runId, readErrorMessage(event.payload) ?? "Hermes run failed", event);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
await this.persistHermesEvent(conversationId, runId, event);
|
|
701
|
+
}
|
|
702
|
+
await this.completeRun(conversationId, runId);
|
|
703
|
+
}
|
|
704
|
+
async persistHermesEvent(conversationId, runId, event) {
|
|
705
|
+
if (!await this.isConversationActive(conversationId)) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const type = event.payloadType;
|
|
709
|
+
if (type === "message.delta") {
|
|
710
|
+
const delta = readDelta(event.payload);
|
|
711
|
+
if (delta) {
|
|
712
|
+
await this.appendAssistantDelta(conversationId, runId, delta, event.payload);
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const messageId = await this.assistantMessageIdForRun(conversationId, runId);
|
|
717
|
+
await this.appendEvent(conversationId, {
|
|
718
|
+
type,
|
|
719
|
+
message_id: messageId,
|
|
720
|
+
run_id: runId,
|
|
721
|
+
payload: event.payload,
|
|
722
|
+
raw: { format: "hermes-run-event", payload: event.rawPayload }
|
|
723
|
+
});
|
|
724
|
+
if (messageId) {
|
|
725
|
+
await this.importMediaReferences(conversationId, runId, messageId, collectMediaReferences(event.payload));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
async importMediaReferencesForEvent(conversationId, runId, event) {
|
|
729
|
+
const messageId = await this.assistantMessageIdForRun(conversationId, runId);
|
|
730
|
+
if (!messageId) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
await this.importMediaReferences(conversationId, runId, messageId, collectMediaReferences(event.payload));
|
|
734
|
+
}
|
|
735
|
+
async appendAssistantDelta(conversationId, runId, delta, rawPayload) {
|
|
736
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
737
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
738
|
+
if (!run) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const assistant = snapshot.messages.find((message) => message.id === run.assistant_message_id);
|
|
742
|
+
if (!assistant) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const textPart = assistant.parts.find((part) => part.type === "text");
|
|
746
|
+
if (textPart) {
|
|
747
|
+
textPart.text = `${textPart.text ?? ""}${delta}`;
|
|
748
|
+
} else {
|
|
749
|
+
assistant.parts.push({ type: "text", text: delta });
|
|
750
|
+
}
|
|
751
|
+
assistant.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
752
|
+
assistant.raw = { format: "hermes-run-event", payload: rawPayload };
|
|
753
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
754
|
+
await this.appendEvent(conversationId, {
|
|
755
|
+
type: "message.delta",
|
|
756
|
+
message_id: assistant.id,
|
|
757
|
+
run_id: runId,
|
|
758
|
+
payload: { delta },
|
|
759
|
+
raw: { format: "hermes-run-event", payload: rawPayload }
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
async completeRun(conversationId, runId, source) {
|
|
763
|
+
let snapshot = await this.readSnapshot(conversationId);
|
|
764
|
+
let run = snapshot.runs.find((item) => item.id === runId);
|
|
765
|
+
if (!run) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const initialRun = run;
|
|
769
|
+
let assistant = snapshot.messages.find((message) => message.id === initialRun.assistant_message_id);
|
|
770
|
+
if (assistant) {
|
|
771
|
+
await this.importMediaReferences(conversationId, runId, assistant.id, collectMediaTags(messageText(assistant)));
|
|
772
|
+
snapshot = await this.readSnapshot(conversationId);
|
|
773
|
+
const refreshedRun = snapshot.runs.find((item) => item.id === runId);
|
|
774
|
+
if (!refreshedRun) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
run = refreshedRun;
|
|
778
|
+
assistant = snapshot.messages.find((message) => message.id === refreshedRun.assistant_message_id);
|
|
779
|
+
}
|
|
780
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
781
|
+
run.status = "completed";
|
|
782
|
+
run.completed_at = completedAt;
|
|
783
|
+
if (assistant) {
|
|
784
|
+
assistant.status = "completed";
|
|
785
|
+
assistant.updated_at = completedAt;
|
|
786
|
+
cleanMessageTextParts(assistant);
|
|
787
|
+
}
|
|
788
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
789
|
+
if (assistant) {
|
|
790
|
+
await this.appendEvent(conversationId, {
|
|
791
|
+
type: "message.completed",
|
|
792
|
+
message_id: assistant.id,
|
|
793
|
+
run_id: runId,
|
|
794
|
+
payload: { message: assistant }
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
await this.appendEvent(conversationId, {
|
|
798
|
+
type: "run.completed",
|
|
799
|
+
message_id: assistant?.id,
|
|
800
|
+
run_id: runId,
|
|
801
|
+
payload: { run, ...source ? { hermes: source.payload } : {} },
|
|
802
|
+
...source ? { raw: { format: "hermes-run-event", payload: source.rawPayload } } : {}
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
async failRun(conversationId, runId, message, source) {
|
|
806
|
+
const snapshot = await this.readSnapshot(conversationId).catch(() => null);
|
|
807
|
+
if (!snapshot) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
811
|
+
if (!run) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
run.status = "failed";
|
|
815
|
+
run.completed_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
816
|
+
run.error_message = message;
|
|
817
|
+
const assistant = snapshot.messages.find((item) => item.id === run.assistant_message_id);
|
|
818
|
+
if (assistant) {
|
|
819
|
+
assistant.status = "failed";
|
|
820
|
+
assistant.updated_at = run.completed_at;
|
|
821
|
+
const textPart = assistant.parts.find((part) => part.type === "text");
|
|
822
|
+
if (textPart && !textPart.text) {
|
|
823
|
+
textPart.text = message;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
827
|
+
await this.appendEvent(conversationId, {
|
|
828
|
+
type: "run.failed",
|
|
829
|
+
message_id: assistant?.id,
|
|
830
|
+
run_id: runId,
|
|
831
|
+
payload: { error: { message }, run, ...source ? { hermes: source.payload } : {} },
|
|
832
|
+
...source ? { raw: { format: "hermes-run-event", payload: source.rawPayload } } : {}
|
|
833
|
+
});
|
|
834
|
+
void this.logger.warn("conversation_run_failed", { conversation_id: conversationId, run_id: runId, error: message });
|
|
835
|
+
}
|
|
836
|
+
async updateRun(conversationId, runId, patch) {
|
|
837
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
838
|
+
const run = snapshot.runs.find((item) => item.id === runId);
|
|
839
|
+
if (!run) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
Object.assign(run, patch);
|
|
843
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
844
|
+
}
|
|
845
|
+
async assistantMessageIdForRun(conversationId, runId) {
|
|
846
|
+
const snapshot = await this.readSnapshot(conversationId).catch(() => null);
|
|
847
|
+
return snapshot?.runs.find((item) => item.id === runId)?.assistant_message_id;
|
|
848
|
+
}
|
|
849
|
+
async importMediaReferences(conversationId, runId, messageId, references) {
|
|
850
|
+
if (references.length === 0) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const snapshot = await this.readSnapshot(conversationId);
|
|
854
|
+
const assistant = snapshot.messages.find((message) => message.id === messageId);
|
|
855
|
+
if (!assistant) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const importedSourceKeys = readImportedMediaSourceKeys(assistant);
|
|
859
|
+
const importedParts = [];
|
|
860
|
+
for (const reference of references.slice(0, 10)) {
|
|
861
|
+
try {
|
|
862
|
+
const sourceKey = mediaSourceKey(reference.path);
|
|
863
|
+
if (importedSourceKeys.has(sourceKey)) {
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const blob = await this.writeBlobFromFile(conversationId, reference);
|
|
867
|
+
const part = {
|
|
868
|
+
type: reference.kind ?? mediaKindForMime(blob.mime),
|
|
869
|
+
blob: blob.id,
|
|
870
|
+
mime: blob.mime,
|
|
871
|
+
size: blob.size,
|
|
872
|
+
filename: blob.filename,
|
|
873
|
+
url: `/api/v1/conversations/${encodeURIComponent(conversationId)}/blobs/${encodeURIComponent(blob.id)}`
|
|
874
|
+
};
|
|
875
|
+
assistant.parts.push(part);
|
|
876
|
+
assistant.attachments.push({
|
|
877
|
+
blob_id: blob.id,
|
|
878
|
+
mime: blob.mime,
|
|
879
|
+
size: blob.size,
|
|
880
|
+
filename: blob.filename,
|
|
881
|
+
source: "hermes_output"
|
|
882
|
+
});
|
|
883
|
+
importedSourceKeys.add(sourceKey);
|
|
884
|
+
importedParts.push(part);
|
|
885
|
+
} catch (error) {
|
|
886
|
+
void this.logger.warn("conversation_media_import_failed", {
|
|
887
|
+
conversation_id: conversationId,
|
|
888
|
+
run_id: runId,
|
|
889
|
+
message_id: messageId,
|
|
890
|
+
error: error instanceof Error ? error.message : String(error)
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (importedParts.length === 0) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
assistant.hermes = {
|
|
898
|
+
...toRecord2(assistant.hermes),
|
|
899
|
+
imported_media_source_keys: [...importedSourceKeys]
|
|
900
|
+
};
|
|
901
|
+
assistant.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
902
|
+
await this.writeSnapshot(conversationId, snapshot);
|
|
903
|
+
await this.appendEvent(conversationId, {
|
|
904
|
+
type: "message.parts.created",
|
|
905
|
+
message_id: messageId,
|
|
906
|
+
run_id: runId,
|
|
907
|
+
payload: { parts: importedParts }
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
async appendEvent(conversationId, input) {
|
|
911
|
+
const manifest = await this.readManifest(conversationId);
|
|
912
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
913
|
+
const event = {
|
|
914
|
+
...input,
|
|
915
|
+
seq: manifest.last_event_seq + 1,
|
|
916
|
+
conversation_id: conversationId,
|
|
917
|
+
created_at: now
|
|
918
|
+
};
|
|
919
|
+
await mkdir3(this.conversationDir(conversationId), { recursive: true, mode: 448 });
|
|
920
|
+
await appendFile(this.eventsPath(conversationId), `${JSON.stringify(event)}
|
|
921
|
+
`, { mode: 384 });
|
|
922
|
+
await this.writeManifest({
|
|
923
|
+
...manifest,
|
|
924
|
+
last_event_seq: event.seq,
|
|
925
|
+
updated_at: now
|
|
926
|
+
});
|
|
927
|
+
this.emitter.emit(this.liveEventName(conversationId), event);
|
|
928
|
+
return event;
|
|
929
|
+
}
|
|
930
|
+
async readActiveManifest(conversationId) {
|
|
931
|
+
const manifest = await this.readManifest(conversationId);
|
|
932
|
+
if (manifest.status !== "active") {
|
|
933
|
+
throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
|
|
934
|
+
}
|
|
935
|
+
return manifest;
|
|
936
|
+
}
|
|
937
|
+
async isConversationActive(conversationId) {
|
|
938
|
+
const manifest = await this.readManifest(conversationId).catch(() => null);
|
|
939
|
+
return manifest?.status === "active";
|
|
940
|
+
}
|
|
941
|
+
async readManifest(conversationId) {
|
|
942
|
+
const manifest = await readJsonFile(this.manifestPath(conversationId));
|
|
943
|
+
if (!manifest) {
|
|
944
|
+
throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
|
|
945
|
+
}
|
|
946
|
+
return manifest;
|
|
947
|
+
}
|
|
948
|
+
writeManifest(manifest) {
|
|
949
|
+
return writeJsonFile(this.manifestPath(manifest.id), manifest);
|
|
950
|
+
}
|
|
951
|
+
async readSnapshot(conversationId) {
|
|
952
|
+
return await readJsonFile(this.snapshotPath(conversationId)) ?? emptySnapshot();
|
|
953
|
+
}
|
|
954
|
+
writeSnapshot(conversationId, snapshot) {
|
|
955
|
+
return writeJsonFile(this.snapshotPath(conversationId), snapshot);
|
|
956
|
+
}
|
|
957
|
+
conversationDir(conversationId) {
|
|
958
|
+
assertValidConversationId(conversationId);
|
|
959
|
+
return path4.join(this.paths.conversationsDir, conversationId);
|
|
960
|
+
}
|
|
961
|
+
manifestPath(conversationId) {
|
|
962
|
+
return path4.join(this.conversationDir(conversationId), "manifest.json");
|
|
963
|
+
}
|
|
964
|
+
snapshotPath(conversationId) {
|
|
965
|
+
return path4.join(this.conversationDir(conversationId), "snapshot.json");
|
|
966
|
+
}
|
|
967
|
+
eventsPath(conversationId) {
|
|
968
|
+
return path4.join(this.conversationDir(conversationId), "events.ndjson");
|
|
969
|
+
}
|
|
970
|
+
blobPath(blobId) {
|
|
971
|
+
assertValidBlobId(blobId);
|
|
972
|
+
return path4.join(this.paths.blobsDir, `${blobId}.bin`);
|
|
973
|
+
}
|
|
974
|
+
liveEventName(conversationId) {
|
|
975
|
+
return `conversation:${conversationId}`;
|
|
976
|
+
}
|
|
977
|
+
async pruneConversationBlobReferences(conversationId, blobIds) {
|
|
978
|
+
for (const blobId of blobIds) {
|
|
979
|
+
try {
|
|
980
|
+
const manifestPath = `${this.blobPath(blobId)}.json`;
|
|
981
|
+
const manifest = await readJsonFile(manifestPath);
|
|
982
|
+
const nextConversationIds = (manifest?.conversation_ids ?? []).filter((id) => id !== conversationId);
|
|
983
|
+
if (nextConversationIds.length > 0) {
|
|
984
|
+
await writeJsonFile(manifestPath, { ...manifest, conversation_ids: nextConversationIds });
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
await rm2(this.blobPath(blobId), { force: true });
|
|
988
|
+
await rm2(manifestPath, { force: true });
|
|
989
|
+
} catch (error) {
|
|
990
|
+
void this.logger.warn("conversation_blob_gc_failed", {
|
|
991
|
+
conversation_id: conversationId,
|
|
992
|
+
blob_id: blobId,
|
|
993
|
+
error: error instanceof Error ? error.message : String(error)
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async listConversationBlobIds(conversationId) {
|
|
999
|
+
await mkdir3(this.paths.blobsDir, { recursive: true, mode: 448 });
|
|
1000
|
+
const entries = await readdir(this.paths.blobsDir, { withFileTypes: true }).catch((error) => {
|
|
1001
|
+
if (isNodeError3(error, "ENOENT")) {
|
|
1002
|
+
return [];
|
|
1003
|
+
}
|
|
1004
|
+
throw error;
|
|
1005
|
+
});
|
|
1006
|
+
const blobIds = [];
|
|
1007
|
+
for (const entry of entries) {
|
|
1008
|
+
if (!entry.isFile() || !entry.name.endsWith(".bin.json")) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
const blobId = entry.name.slice(0, -".bin.json".length);
|
|
1012
|
+
if (!BLOB_ID_PATTERN.test(blobId)) {
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const manifest = await readJsonFile(
|
|
1016
|
+
path4.join(this.paths.blobsDir, entry.name)
|
|
1017
|
+
).catch(() => null);
|
|
1018
|
+
if (manifest?.conversation_ids?.includes(conversationId)) {
|
|
1019
|
+
blobIds.push(blobId);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return blobIds;
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
async function* parseSseResponse(response) {
|
|
1026
|
+
if (!response.body) {
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const decoder = new TextDecoder();
|
|
1030
|
+
let buffer = "";
|
|
1031
|
+
for await (const chunk of response.body) {
|
|
1032
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1033
|
+
let separatorIndex = buffer.indexOf("\n\n");
|
|
1034
|
+
while (separatorIndex >= 0) {
|
|
1035
|
+
const block = buffer.slice(0, separatorIndex);
|
|
1036
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
1037
|
+
const parsed = parseSseBlock(block);
|
|
1038
|
+
if (parsed) {
|
|
1039
|
+
yield parsed;
|
|
1040
|
+
}
|
|
1041
|
+
separatorIndex = buffer.indexOf("\n\n");
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const trailing = parseSseBlock(buffer);
|
|
1045
|
+
if (trailing) {
|
|
1046
|
+
yield trailing;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function parseSseBlock(block) {
|
|
1050
|
+
const lines = block.split("\n");
|
|
1051
|
+
let eventName = "";
|
|
1052
|
+
const data = [];
|
|
1053
|
+
for (const rawLine of lines) {
|
|
1054
|
+
const line = rawLine.trimEnd();
|
|
1055
|
+
if (line.startsWith("event:")) {
|
|
1056
|
+
eventName = line.slice(6).trim();
|
|
1057
|
+
} else if (line.startsWith("data:")) {
|
|
1058
|
+
data.push(line.slice(5).trimStart());
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
if (!eventName && data.length === 0) {
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
const raw = data.join("\n");
|
|
1065
|
+
const decoded = decodeJson(raw);
|
|
1066
|
+
const payload = toRecord2(decoded);
|
|
1067
|
+
const payloadType = (readString2(payload, "type") ?? readString2(payload, "event") ?? eventName) || "message";
|
|
1068
|
+
return { eventName, payloadType, payload, rawPayload: decoded ?? raw };
|
|
1069
|
+
}
|
|
1070
|
+
function decodeJson(value) {
|
|
1071
|
+
if (!value.trim()) {
|
|
1072
|
+
return {};
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
return JSON.parse(value);
|
|
1076
|
+
} catch {
|
|
1077
|
+
return { type: "message.delta", delta: value };
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function emptySnapshot() {
|
|
1081
|
+
return { schema_version: 1, messages: [], runs: [] };
|
|
1082
|
+
}
|
|
1083
|
+
function toSummary(manifest, snapshot) {
|
|
1084
|
+
const lastMessage = [...snapshot.messages].reverse().find((message) => message.parts.some((part) => part.type === "text" && part.text));
|
|
1085
|
+
return {
|
|
1086
|
+
id: manifest.id,
|
|
1087
|
+
title: manifest.title,
|
|
1088
|
+
created_at: manifest.created_at,
|
|
1089
|
+
updated_at: manifest.updated_at,
|
|
1090
|
+
last_event_seq: manifest.last_event_seq,
|
|
1091
|
+
last_message: lastMessage ? {
|
|
1092
|
+
id: lastMessage.id,
|
|
1093
|
+
role: lastMessage.role,
|
|
1094
|
+
content_preview: previewText(lastMessage)
|
|
1095
|
+
} : null
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
function previewText(message) {
|
|
1099
|
+
return messageText(message).slice(0, 160);
|
|
1100
|
+
}
|
|
1101
|
+
function messageText(message) {
|
|
1102
|
+
return message.parts.filter((part) => part.type === "text" && part.text).map((part) => part.text).join("").trim();
|
|
1103
|
+
}
|
|
1104
|
+
function buildConversationHistory(snapshot, run) {
|
|
1105
|
+
const triggerIndex = snapshot.messages.findIndex((message) => message.id === run.trigger_message_id);
|
|
1106
|
+
const previousMessages = triggerIndex >= 0 ? snapshot.messages.slice(0, triggerIndex) : snapshot.messages;
|
|
1107
|
+
return previousMessages.filter((message) => (message.role === "user" || message.role === "assistant") && message.status === "completed").map((message) => ({
|
|
1108
|
+
role: message.role,
|
|
1109
|
+
content: messageText(message)
|
|
1110
|
+
})).filter((message) => message.content);
|
|
1111
|
+
}
|
|
1112
|
+
function readDelta(payload) {
|
|
1113
|
+
return readString2(payload, "delta") ?? readString2(payload, "text") ?? readString2(payload, "content");
|
|
1114
|
+
}
|
|
1115
|
+
function readErrorMessage(payload) {
|
|
1116
|
+
const error = toRecord2(payload.error);
|
|
1117
|
+
return readString2(error, "message") ?? readString2(payload, "message");
|
|
1118
|
+
}
|
|
1119
|
+
function collectMediaReferences(payload) {
|
|
1120
|
+
const references = [];
|
|
1121
|
+
collectStructuredMediaReferences(payload, references);
|
|
1122
|
+
for (const key of ["output", "content", "message", "text"]) {
|
|
1123
|
+
const value = payload[key];
|
|
1124
|
+
if (typeof value === "string") {
|
|
1125
|
+
references.push(...collectMediaTags(value));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const unique2 = /* @__PURE__ */ new Set();
|
|
1129
|
+
return references.filter((reference) => {
|
|
1130
|
+
const key = `${reference.path}|${reference.mime ?? ""}|${reference.kind ?? ""}`;
|
|
1131
|
+
if (unique2.has(key)) {
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
unique2.add(key);
|
|
1135
|
+
return true;
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
function collectStructuredMediaReferences(value, references, depth = 0) {
|
|
1139
|
+
if (depth > 4 || value === null || typeof value !== "object") {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (Array.isArray(value)) {
|
|
1143
|
+
for (const item of value) {
|
|
1144
|
+
collectStructuredMediaReferences(item, references, depth + 1);
|
|
1145
|
+
}
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const record = value;
|
|
1149
|
+
for (const [key, item] of Object.entries(record)) {
|
|
1150
|
+
if (typeof item === "string" && isExplicitMediaPathKey(key)) {
|
|
1151
|
+
references.push({
|
|
1152
|
+
path: item,
|
|
1153
|
+
kind: mediaKindForMime(readString2(record, "mime") ?? readString2(record, "mime_type") ?? inferMimeType(item)),
|
|
1154
|
+
mime: readString2(record, "mime") ?? readString2(record, "mime_type") ?? void 0
|
|
1155
|
+
});
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
if (Array.isArray(item) || item !== null && typeof item === "object") {
|
|
1159
|
+
collectStructuredMediaReferences(item, references, depth + 1);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
function collectMediaTags(text) {
|
|
1164
|
+
const references = [];
|
|
1165
|
+
for (const match of text.matchAll(MEDIA_TAG_PATTERN)) {
|
|
1166
|
+
const rawPath = match[1]?.trim();
|
|
1167
|
+
const mediaPath = rawPath?.replace(/^["'`]|["'`]$/gu, "").replace(/["'`,.;:)}\]]+$/gu, "");
|
|
1168
|
+
if (mediaPath) {
|
|
1169
|
+
references.push({
|
|
1170
|
+
path: mediaPath,
|
|
1171
|
+
kind: mediaKindForMime(inferMimeType(mediaPath))
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return references;
|
|
1176
|
+
}
|
|
1177
|
+
function cleanMessageTextParts(message) {
|
|
1178
|
+
for (const part of message.parts) {
|
|
1179
|
+
if (part.type === "text" && part.text) {
|
|
1180
|
+
part.text = cleanHermesDisplayText(part.text);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
function cleanHermesDisplayText(text) {
|
|
1185
|
+
if (!text.includes("MEDIA:") && !text.includes("[[audio_as_voice]]")) {
|
|
1186
|
+
return text;
|
|
1187
|
+
}
|
|
1188
|
+
return text.replaceAll("[[audio_as_voice]]", "").replace(MEDIA_TAG_PATTERN, "").replace(/\n{3,}/gu, "\n\n").trimEnd();
|
|
1189
|
+
}
|
|
1190
|
+
function isExplicitMediaPathKey(key) {
|
|
1191
|
+
return [
|
|
1192
|
+
"file_path",
|
|
1193
|
+
"screenshot_path",
|
|
1194
|
+
"image_path",
|
|
1195
|
+
"audio_path",
|
|
1196
|
+
"video_path",
|
|
1197
|
+
"media_path",
|
|
1198
|
+
"artifact_path"
|
|
1199
|
+
].includes(key);
|
|
1200
|
+
}
|
|
1201
|
+
function inferMimeType(filePath) {
|
|
1202
|
+
const extension = path4.extname(filePath).toLowerCase();
|
|
1203
|
+
return {
|
|
1204
|
+
".png": "image/png",
|
|
1205
|
+
".jpg": "image/jpeg",
|
|
1206
|
+
".jpeg": "image/jpeg",
|
|
1207
|
+
".gif": "image/gif",
|
|
1208
|
+
".webp": "image/webp",
|
|
1209
|
+
".heic": "image/heic",
|
|
1210
|
+
".pdf": "application/pdf",
|
|
1211
|
+
".txt": "text/plain",
|
|
1212
|
+
".md": "text/markdown",
|
|
1213
|
+
".json": "application/json",
|
|
1214
|
+
".csv": "text/csv",
|
|
1215
|
+
".mp3": "audio/mpeg",
|
|
1216
|
+
".wav": "audio/wav",
|
|
1217
|
+
".m4a": "audio/mp4",
|
|
1218
|
+
".ogg": "audio/ogg",
|
|
1219
|
+
".opus": "audio/ogg",
|
|
1220
|
+
".mp4": "video/mp4",
|
|
1221
|
+
".mov": "video/quicktime",
|
|
1222
|
+
".webm": "video/webm"
|
|
1223
|
+
}[extension] ?? "application/octet-stream";
|
|
1224
|
+
}
|
|
1225
|
+
function mediaKindForMime(mime) {
|
|
1226
|
+
if (mime.startsWith("image/")) {
|
|
1227
|
+
return "image";
|
|
1228
|
+
}
|
|
1229
|
+
if (mime.startsWith("audio/")) {
|
|
1230
|
+
return "audio";
|
|
1231
|
+
}
|
|
1232
|
+
return "file";
|
|
1233
|
+
}
|
|
1234
|
+
function collectBlobIds(snapshot) {
|
|
1235
|
+
const blobIds = /* @__PURE__ */ new Set();
|
|
1236
|
+
for (const message of snapshot.messages) {
|
|
1237
|
+
for (const part of message.parts) {
|
|
1238
|
+
if (part.blob) {
|
|
1239
|
+
blobIds.add(part.blob);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
for (const attachment of message.attachments) {
|
|
1243
|
+
const record = toRecord2(attachment);
|
|
1244
|
+
const blobId = readString2(record, "blob_id") ?? readString2(record, "blobId");
|
|
1245
|
+
if (blobId) {
|
|
1246
|
+
blobIds.add(blobId);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return [...blobIds];
|
|
1251
|
+
}
|
|
1252
|
+
function readImportedMediaSourceKeys(message) {
|
|
1253
|
+
const hermes = toRecord2(message.hermes);
|
|
1254
|
+
const keys = hermes.imported_media_source_keys;
|
|
1255
|
+
if (!Array.isArray(keys)) {
|
|
1256
|
+
return /* @__PURE__ */ new Set();
|
|
1257
|
+
}
|
|
1258
|
+
return new Set(keys.filter((key) => typeof key === "string" && key.length > 0));
|
|
1259
|
+
}
|
|
1260
|
+
function mediaSourceKey(sourcePath) {
|
|
1261
|
+
return createHash("sha256").update(resolveMediaSourcePath(sourcePath)).digest("hex").slice(0, 32);
|
|
1262
|
+
}
|
|
1263
|
+
function sanitizeFilename(value, fallback) {
|
|
1264
|
+
const base = path4.basename((value ?? "").replace(/[\r\n\t]/gu, " ").trim());
|
|
1265
|
+
const safe = base.replace(/[/:\\]/gu, "_").slice(0, 200).trim();
|
|
1266
|
+
return safe || fallback;
|
|
1267
|
+
}
|
|
1268
|
+
function resolveMediaSourcePath(sourcePath) {
|
|
1269
|
+
const trimmed = sourcePath.trim();
|
|
1270
|
+
const expanded = trimmed.startsWith("~/") ? path4.join(process.env.HOME ?? "", trimmed.slice(2)) : trimmed;
|
|
1271
|
+
const resolved = path4.resolve(expanded);
|
|
1272
|
+
if (!path4.isAbsolute(expanded)) {
|
|
1273
|
+
throw new LinkHttpError(400, "media_source_path_not_absolute", "Hermes output media source must be an absolute path");
|
|
1274
|
+
}
|
|
1275
|
+
return resolved;
|
|
1276
|
+
}
|
|
1277
|
+
function assertValidConversationId(conversationId) {
|
|
1278
|
+
if (!CONVERSATION_ID_PATTERN.test(conversationId)) {
|
|
1279
|
+
throw new LinkHttpError(404, "conversation_not_found", "Conversation was not found");
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
function assertValidBlobId(blobId) {
|
|
1283
|
+
if (!BLOB_ID_PATTERN.test(blobId)) {
|
|
1284
|
+
throw new LinkHttpError(404, "blob_not_found", "Blob was not found");
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function readString2(payload, key) {
|
|
1288
|
+
const value = payload[key];
|
|
1289
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
1290
|
+
}
|
|
1291
|
+
function toRecord2(value) {
|
|
1292
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
1293
|
+
}
|
|
1294
|
+
function isNodeError3(error, code) {
|
|
1295
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
317
1298
|
// src/hermes/profiles.ts
|
|
318
|
-
import { mkdir as
|
|
1299
|
+
import { mkdir as mkdir4, readdir as readdir2, rename as rename2, rm as rm3, stat as stat2 } from "fs/promises";
|
|
319
1300
|
import os3 from "os";
|
|
320
|
-
import
|
|
1301
|
+
import path5 from "path";
|
|
321
1302
|
var DEFAULT_PROFILE = "default";
|
|
322
1303
|
var PROFILE_NAME_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
323
1304
|
async function listHermesProfiles(paths = resolveRuntimePaths()) {
|
|
324
1305
|
const activeProfile = await getActiveProfile(paths);
|
|
325
1306
|
const profiles = /* @__PURE__ */ new Map();
|
|
326
1307
|
profiles.set(DEFAULT_PROFILE, profileInfo(DEFAULT_PROFILE, activeProfile));
|
|
327
|
-
const profilesDir =
|
|
328
|
-
const entries = await
|
|
329
|
-
if (
|
|
1308
|
+
const profilesDir = path5.join(os3.homedir(), ".hermes", "profiles");
|
|
1309
|
+
const entries = await readdir2(profilesDir, { withFileTypes: true }).catch((error) => {
|
|
1310
|
+
if (isNodeError4(error, "ENOENT")) {
|
|
330
1311
|
return [];
|
|
331
1312
|
}
|
|
332
1313
|
throw error;
|
|
@@ -349,8 +1330,8 @@ async function listHermesProfiles(paths = resolveRuntimePaths()) {
|
|
|
349
1330
|
async function getHermesProfileStatus(name, paths = resolveRuntimePaths()) {
|
|
350
1331
|
assertProfileName(name);
|
|
351
1332
|
const profile = profileInfo(name, await getActiveProfile(paths));
|
|
352
|
-
const exists = await
|
|
353
|
-
if (
|
|
1333
|
+
const exists = await stat2(profile.path).then((value) => value.isDirectory()).catch((error) => {
|
|
1334
|
+
if (isNodeError4(error, "ENOENT")) {
|
|
354
1335
|
return false;
|
|
355
1336
|
}
|
|
356
1337
|
throw error;
|
|
@@ -371,14 +1352,14 @@ async function createHermesProfile(name) {
|
|
|
371
1352
|
if (await pathExists(profile.path)) {
|
|
372
1353
|
throw new Error("profile already exists");
|
|
373
1354
|
}
|
|
374
|
-
await
|
|
1355
|
+
await mkdir4(profile.path, { recursive: true, mode: 448 });
|
|
375
1356
|
await ensureHermesApiServerKey(name, profile.configPath);
|
|
376
1357
|
return profile;
|
|
377
1358
|
}
|
|
378
1359
|
async function useHermesProfile(name, paths = resolveRuntimePaths()) {
|
|
379
1360
|
assertProfileName(name);
|
|
380
1361
|
const profile = profileInfo(name, name);
|
|
381
|
-
await
|
|
1362
|
+
await mkdir4(profile.path, { recursive: true, mode: 448 });
|
|
382
1363
|
const current = await readJsonFile(paths.stateFile) ?? {};
|
|
383
1364
|
await writeJsonFile(paths.stateFile, { ...current, activeProfile: name });
|
|
384
1365
|
return profile;
|
|
@@ -393,7 +1374,7 @@ async function renameHermesProfile(oldName, newName) {
|
|
|
393
1374
|
}
|
|
394
1375
|
async function deleteHermesProfile(name) {
|
|
395
1376
|
assertMutableProfile(name);
|
|
396
|
-
await
|
|
1377
|
+
await rm3(resolveHermesProfileDir(name), { recursive: true, force: true });
|
|
397
1378
|
}
|
|
398
1379
|
async function getActiveProfile(paths) {
|
|
399
1380
|
const state = await readJsonFile(paths.stateFile);
|
|
@@ -419,20 +1400,20 @@ function assertProfileName(name) {
|
|
|
419
1400
|
}
|
|
420
1401
|
}
|
|
421
1402
|
async function pathExists(targetPath) {
|
|
422
|
-
return await
|
|
423
|
-
if (
|
|
1403
|
+
return await stat2(targetPath).then(() => true).catch((error) => {
|
|
1404
|
+
if (isNodeError4(error, "ENOENT")) {
|
|
424
1405
|
return false;
|
|
425
1406
|
}
|
|
426
1407
|
throw error;
|
|
427
1408
|
});
|
|
428
1409
|
}
|
|
429
|
-
function
|
|
1410
|
+
function isNodeError4(error, code) {
|
|
430
1411
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
431
1412
|
}
|
|
432
1413
|
|
|
433
1414
|
// src/identity/identity.ts
|
|
434
|
-
import { generateKeyPairSync, randomUUID as
|
|
435
|
-
import { mkdir as
|
|
1415
|
+
import { generateKeyPairSync, randomUUID as randomUUID3, sign } from "crypto";
|
|
1416
|
+
import { mkdir as mkdir5, chmod } from "fs/promises";
|
|
436
1417
|
import { z } from "zod";
|
|
437
1418
|
var linkIdentitySchema = z.object({
|
|
438
1419
|
install_id: z.string().min(1),
|
|
@@ -454,12 +1435,12 @@ async function ensureIdentity(paths = resolveRuntimePaths()) {
|
|
|
454
1435
|
if (existing) {
|
|
455
1436
|
return existing;
|
|
456
1437
|
}
|
|
457
|
-
await
|
|
1438
|
+
await mkdir5(paths.homeDir, { recursive: true, mode: 448 });
|
|
458
1439
|
await chmod(paths.homeDir, 448).catch(() => void 0);
|
|
459
1440
|
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
460
1441
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
461
1442
|
const identity = {
|
|
462
|
-
install_id: `install_${
|
|
1443
|
+
install_id: `install_${randomUUID3().replaceAll("-", "")}`,
|
|
463
1444
|
link_id: null,
|
|
464
1445
|
public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
465
1446
|
private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
@@ -493,11 +1474,11 @@ function getIdentityStatus(identity) {
|
|
|
493
1474
|
}
|
|
494
1475
|
|
|
495
1476
|
// src/pairing/pairing.ts
|
|
496
|
-
import
|
|
497
|
-
import { rm as
|
|
1477
|
+
import path6 from "path";
|
|
1478
|
+
import { rm as rm4 } from "fs/promises";
|
|
498
1479
|
|
|
499
1480
|
// src/security/devices.ts
|
|
500
|
-
import { randomBytes as randomBytes2, randomUUID as
|
|
1481
|
+
import { randomBytes as randomBytes2, randomUUID as randomUUID4, timingSafeEqual, createHash as createHash2 } from "crypto";
|
|
501
1482
|
var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
|
|
502
1483
|
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
|
|
503
1484
|
async function createDeviceSession(input, paths = resolveRuntimePaths()) {
|
|
@@ -506,7 +1487,7 @@ async function createDeviceSession(input, paths = resolveRuntimePaths()) {
|
|
|
506
1487
|
const accessToken = randomToken("hpat_");
|
|
507
1488
|
const refreshToken = randomToken("hprt_");
|
|
508
1489
|
const device = {
|
|
509
|
-
id: `dev_${
|
|
1490
|
+
id: `dev_${randomUUID4().replaceAll("-", "")}`,
|
|
510
1491
|
label: input.label,
|
|
511
1492
|
platform: input.platform,
|
|
512
1493
|
scope: "admin",
|
|
@@ -595,7 +1576,7 @@ function randomToken(prefix) {
|
|
|
595
1576
|
return `${prefix}${randomBytes2(24).toString("base64url")}`;
|
|
596
1577
|
}
|
|
597
1578
|
function sha256(value) {
|
|
598
|
-
return
|
|
1579
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
599
1580
|
}
|
|
600
1581
|
function safeEqual(left, right) {
|
|
601
1582
|
const leftBytes = Buffer.from(left);
|
|
@@ -654,7 +1635,7 @@ async function postJson(fetcher, url, token, body) {
|
|
|
654
1635
|
});
|
|
655
1636
|
const payload = await response.json().catch(() => null);
|
|
656
1637
|
if (!response.ok) {
|
|
657
|
-
const message =
|
|
1638
|
+
const message = readErrorMessage2(payload) ?? `Relay request failed with HTTP ${response.status}`;
|
|
658
1639
|
throw new Error(message);
|
|
659
1640
|
}
|
|
660
1641
|
if (!payload) {
|
|
@@ -662,7 +1643,7 @@ async function postJson(fetcher, url, token, body) {
|
|
|
662
1643
|
}
|
|
663
1644
|
return payload;
|
|
664
1645
|
}
|
|
665
|
-
function
|
|
1646
|
+
function readErrorMessage2(payload) {
|
|
666
1647
|
if (typeof payload !== "object" || payload === null) {
|
|
667
1648
|
return null;
|
|
668
1649
|
}
|
|
@@ -916,7 +1897,7 @@ async function readPairingClaim(sessionId, paths = resolveRuntimePaths()) {
|
|
|
916
1897
|
};
|
|
917
1898
|
}
|
|
918
1899
|
async function clearPairingClaim(sessionId, paths = resolveRuntimePaths()) {
|
|
919
|
-
await
|
|
1900
|
+
await rm4(pairingClaimPath(sessionId, paths), { force: true }).catch(() => void 0);
|
|
920
1901
|
}
|
|
921
1902
|
async function claimPairing(input) {
|
|
922
1903
|
const paths = input.paths ?? resolveRuntimePaths();
|
|
@@ -961,8 +1942,8 @@ async function loadRequiredIdentity(paths) {
|
|
|
961
1942
|
}
|
|
962
1943
|
return identity;
|
|
963
1944
|
}
|
|
964
|
-
async function postServerJson(serverBaseUrl,
|
|
965
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${
|
|
1945
|
+
async function postServerJson(serverBaseUrl, path8, body) {
|
|
1946
|
+
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path8}`, {
|
|
966
1947
|
method: "POST",
|
|
967
1948
|
headers: {
|
|
968
1949
|
accept: "application/json",
|
|
@@ -972,8 +1953,8 @@ async function postServerJson(serverBaseUrl, path7, body) {
|
|
|
972
1953
|
});
|
|
973
1954
|
return readJsonResponse2(response);
|
|
974
1955
|
}
|
|
975
|
-
async function patchServerJson(serverBaseUrl,
|
|
976
|
-
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${
|
|
1956
|
+
async function patchServerJson(serverBaseUrl, path8, token, body) {
|
|
1957
|
+
const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path8}`, {
|
|
977
1958
|
method: "PATCH",
|
|
978
1959
|
headers: {
|
|
979
1960
|
accept: "application/json",
|
|
@@ -987,12 +1968,12 @@ async function patchServerJson(serverBaseUrl, path7, token, body) {
|
|
|
987
1968
|
async function readJsonResponse2(response) {
|
|
988
1969
|
const payload = await response.json().catch(() => null);
|
|
989
1970
|
if (!response.ok || !payload) {
|
|
990
|
-
const message =
|
|
1971
|
+
const message = readErrorMessage3(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
|
|
991
1972
|
throw new LinkHttpError(response.status, "server_request_failed", message);
|
|
992
1973
|
}
|
|
993
1974
|
return payload;
|
|
994
1975
|
}
|
|
995
|
-
function
|
|
1976
|
+
function readErrorMessage3(payload) {
|
|
996
1977
|
if (typeof payload !== "object" || payload === null) {
|
|
997
1978
|
return null;
|
|
998
1979
|
}
|
|
@@ -1007,7 +1988,7 @@ function defaultDisplayName() {
|
|
|
1007
1988
|
return `Hermes Link ${process.platform}`;
|
|
1008
1989
|
}
|
|
1009
1990
|
function pairingClaimPath(sessionId, paths) {
|
|
1010
|
-
return
|
|
1991
|
+
return path6.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
|
|
1011
1992
|
}
|
|
1012
1993
|
function qrPreferredUrls(routes) {
|
|
1013
1994
|
return routes.preferredUrls.filter((url) => !url.includes("/api/v1/relay/links/")).slice(0, 1);
|
|
@@ -1031,8 +2012,8 @@ async function verifyAppConnectToken(token, options) {
|
|
|
1031
2012
|
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token is malformed");
|
|
1032
2013
|
}
|
|
1033
2014
|
const [encodedHeader, encodedPayload, encodedSignature] = segments;
|
|
1034
|
-
const header =
|
|
1035
|
-
const payload =
|
|
2015
|
+
const header = decodeJson2(encodedHeader);
|
|
2016
|
+
const payload = decodeJson2(encodedPayload);
|
|
1036
2017
|
if (header.alg !== "ES256" || header.typ !== "JWT") {
|
|
1037
2018
|
throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token algorithm is unsupported");
|
|
1038
2019
|
}
|
|
@@ -1074,7 +2055,7 @@ async function getJwks(config, fetcher) {
|
|
|
1074
2055
|
};
|
|
1075
2056
|
return keys;
|
|
1076
2057
|
}
|
|
1077
|
-
function
|
|
2058
|
+
function decodeJson2(value) {
|
|
1078
2059
|
return JSON.parse(Buffer.from(base64UrlToBase64(value), "base64").toString("utf8"));
|
|
1079
2060
|
}
|
|
1080
2061
|
function base64UrlToBase64(value) {
|
|
@@ -1083,8 +2064,8 @@ function base64UrlToBase64(value) {
|
|
|
1083
2064
|
}
|
|
1084
2065
|
|
|
1085
2066
|
// src/runtime/logger.ts
|
|
1086
|
-
import { appendFile, mkdir as
|
|
1087
|
-
import
|
|
2067
|
+
import { appendFile as appendFile2, mkdir as mkdir6, open as open2, readFile as readFile4, rename as rename3, rm as rm5, stat as stat3 } from "fs/promises";
|
|
2068
|
+
import path7 from "path";
|
|
1088
2069
|
var DEFAULT_LOG_FILE = "hermeslink.log";
|
|
1089
2070
|
var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
|
|
1090
2071
|
var DEFAULT_MAX_FILES = 5;
|
|
@@ -1132,22 +2113,22 @@ var FileLogger = class {
|
|
|
1132
2113
|
return this.queue;
|
|
1133
2114
|
}
|
|
1134
2115
|
async appendEntry(entry) {
|
|
1135
|
-
await
|
|
2116
|
+
await mkdir6(this.paths.logsDir, { recursive: true, mode: 448 });
|
|
1136
2117
|
const line = `${JSON.stringify(entry)}
|
|
1137
2118
|
`;
|
|
1138
2119
|
await this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
|
|
1139
|
-
await
|
|
2120
|
+
await appendFile2(this.filePath, line, { mode: 384 });
|
|
1140
2121
|
}
|
|
1141
2122
|
async rotateIfNeeded(nextBytes) {
|
|
1142
|
-
const current = await
|
|
2123
|
+
const current = await stat3(this.filePath).catch(() => null);
|
|
1143
2124
|
if (!current || current.size === 0 || current.size + nextBytes <= this.maxFileBytes) {
|
|
1144
2125
|
return;
|
|
1145
2126
|
}
|
|
1146
2127
|
if (this.maxFiles === 0) {
|
|
1147
|
-
await
|
|
2128
|
+
await rm5(this.filePath, { force: true }).catch(() => void 0);
|
|
1148
2129
|
return;
|
|
1149
2130
|
}
|
|
1150
|
-
await
|
|
2131
|
+
await rm5(rotatedLogFile(this.filePath, this.maxFiles), { force: true }).catch(() => void 0);
|
|
1151
2132
|
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
|
|
1152
2133
|
await moveIfExists(rotatedLogFile(this.filePath, index), rotatedLogFile(this.filePath, index + 1));
|
|
1153
2134
|
}
|
|
@@ -1158,7 +2139,7 @@ function createFileLogger(options = {}) {
|
|
|
1158
2139
|
return new FileLogger(options);
|
|
1159
2140
|
}
|
|
1160
2141
|
function getLinkLogFile(paths = resolveRuntimePaths(), fileName = DEFAULT_LOG_FILE) {
|
|
1161
|
-
return
|
|
2142
|
+
return path7.join(paths.logsDir, fileName);
|
|
1162
2143
|
}
|
|
1163
2144
|
async function readRecentLogEntries(options = {}) {
|
|
1164
2145
|
const paths = options.paths ?? resolveRuntimePaths();
|
|
@@ -1253,12 +2234,12 @@ function isLogLevel(value) {
|
|
|
1253
2234
|
return value === "debug" || value === "info" || value === "warn" || value === "error";
|
|
1254
2235
|
}
|
|
1255
2236
|
async function readTail(filePath, maxBytes) {
|
|
1256
|
-
const info = await
|
|
2237
|
+
const info = await stat3(filePath).catch(() => null);
|
|
1257
2238
|
if (!info || info.size <= 0) {
|
|
1258
2239
|
return null;
|
|
1259
2240
|
}
|
|
1260
2241
|
if (info.size <= maxBytes) {
|
|
1261
|
-
return await
|
|
2242
|
+
return await readFile4(filePath, "utf8").catch(() => null);
|
|
1262
2243
|
}
|
|
1263
2244
|
const handle = await open2(filePath, "r").catch(() => null);
|
|
1264
2245
|
if (!handle) {
|
|
@@ -1274,7 +2255,7 @@ async function readTail(filePath, maxBytes) {
|
|
|
1274
2255
|
}
|
|
1275
2256
|
}
|
|
1276
2257
|
async function moveIfExists(from, to) {
|
|
1277
|
-
await
|
|
2258
|
+
await rm5(to, { force: true }).catch(() => void 0);
|
|
1278
2259
|
await rename3(from, to).catch((error) => {
|
|
1279
2260
|
if (error.code !== "ENOENT") {
|
|
1280
2261
|
throw error;
|
|
@@ -1286,9 +2267,12 @@ function rotatedLogFile(filePath, index) {
|
|
|
1286
2267
|
}
|
|
1287
2268
|
|
|
1288
2269
|
// src/http/app.ts
|
|
2270
|
+
var MAX_JSON_BODY_BYTES = 1024 * 1024;
|
|
2271
|
+
var MAX_BLOB_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
1289
2272
|
async function createApp(options = {}) {
|
|
1290
2273
|
const paths = options.paths ?? resolveRuntimePaths();
|
|
1291
2274
|
const logger = options.logger ?? createFileLogger({ paths });
|
|
2275
|
+
const conversations = new ConversationService(paths, logger);
|
|
1292
2276
|
const app = new Koa();
|
|
1293
2277
|
const router = new Router();
|
|
1294
2278
|
app.use(async (ctx, next) => {
|
|
@@ -1346,22 +2330,25 @@ async function createApp(options = {}) {
|
|
|
1346
2330
|
sse: true,
|
|
1347
2331
|
relay: true,
|
|
1348
2332
|
profiles: true,
|
|
1349
|
-
logs: true
|
|
2333
|
+
logs: true,
|
|
2334
|
+
conversations: true,
|
|
2335
|
+
conversation_delete: true,
|
|
2336
|
+
blobs: true
|
|
1350
2337
|
}
|
|
1351
2338
|
};
|
|
1352
2339
|
});
|
|
1353
2340
|
router.post("/api/v1/pairing/claim", async (ctx) => {
|
|
1354
2341
|
const body = await readJsonBody(ctx.req);
|
|
1355
|
-
const sessionId =
|
|
1356
|
-
const claimToken =
|
|
2342
|
+
const sessionId = readString3(body, "session_id") ?? readString3(body, "sessionId");
|
|
2343
|
+
const claimToken = readString3(body, "claim_token") ?? readString3(body, "claimToken");
|
|
1357
2344
|
if (!sessionId || !claimToken) {
|
|
1358
2345
|
throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
|
|
1359
2346
|
}
|
|
1360
2347
|
const claimed = await claimPairing({
|
|
1361
2348
|
sessionId,
|
|
1362
2349
|
claimToken,
|
|
1363
|
-
deviceLabel:
|
|
1364
|
-
devicePlatform:
|
|
2350
|
+
deviceLabel: readString3(body, "device_label") ?? readString3(body, "deviceLabel") ?? "HermesPilot App",
|
|
2351
|
+
devicePlatform: readString3(body, "device_platform") ?? readString3(body, "devicePlatform") ?? "unknown",
|
|
1365
2352
|
paths
|
|
1366
2353
|
});
|
|
1367
2354
|
ctx.body = claimed;
|
|
@@ -1409,7 +2396,7 @@ async function createApp(options = {}) {
|
|
|
1409
2396
|
});
|
|
1410
2397
|
router.post("/api/v1/auth/refresh", async (ctx) => {
|
|
1411
2398
|
const body = await readJsonBody(ctx.req);
|
|
1412
|
-
const refreshToken =
|
|
2399
|
+
const refreshToken = readString3(body, "refresh_token") ?? readString3(body, "refreshToken");
|
|
1413
2400
|
if (!refreshToken) {
|
|
1414
2401
|
throw new LinkHttpError(400, "refresh_token_required", "refresh_token is required");
|
|
1415
2402
|
}
|
|
@@ -1429,7 +2416,7 @@ async function createApp(options = {}) {
|
|
|
1429
2416
|
});
|
|
1430
2417
|
router.post("/api/v1/auth/logout", async (ctx) => {
|
|
1431
2418
|
const body = await readJsonBody(ctx.req);
|
|
1432
|
-
const refreshToken =
|
|
2419
|
+
const refreshToken = readString3(body, "refresh_token") ?? readString3(body, "refreshToken");
|
|
1433
2420
|
if (refreshToken) {
|
|
1434
2421
|
await revokeDeviceRefreshToken(refreshToken, paths);
|
|
1435
2422
|
}
|
|
@@ -1461,10 +2448,116 @@ async function createApp(options = {}) {
|
|
|
1461
2448
|
await authenticateRequest(ctx, paths);
|
|
1462
2449
|
ctx.body = await listHermesModels();
|
|
1463
2450
|
});
|
|
2451
|
+
router.get("/api/v1/conversations", async (ctx) => {
|
|
2452
|
+
await authenticateRequest(ctx, paths);
|
|
2453
|
+
ctx.set("cache-control", "no-store");
|
|
2454
|
+
ctx.body = {
|
|
2455
|
+
ok: true,
|
|
2456
|
+
conversations: await conversations.listConversations()
|
|
2457
|
+
};
|
|
2458
|
+
});
|
|
2459
|
+
router.post("/api/v1/conversations", async (ctx) => {
|
|
2460
|
+
await authenticateRequest(ctx, paths);
|
|
2461
|
+
const body = await readJsonBody(ctx.req);
|
|
2462
|
+
ctx.status = 201;
|
|
2463
|
+
ctx.body = {
|
|
2464
|
+
ok: true,
|
|
2465
|
+
conversation: await conversations.createConversation({
|
|
2466
|
+
title: readString3(body, "title") ?? void 0
|
|
2467
|
+
})
|
|
2468
|
+
};
|
|
2469
|
+
});
|
|
2470
|
+
router.get("/api/v1/conversations/:conversationId/messages", async (ctx) => {
|
|
2471
|
+
await authenticateRequest(ctx, paths);
|
|
2472
|
+
ctx.set("cache-control", "no-store");
|
|
2473
|
+
const result = await conversations.getMessages(ctx.params.conversationId);
|
|
2474
|
+
ctx.body = {
|
|
2475
|
+
ok: true,
|
|
2476
|
+
conversation_id: ctx.params.conversationId,
|
|
2477
|
+
...result
|
|
2478
|
+
};
|
|
2479
|
+
});
|
|
2480
|
+
router.get("/api/v1/conversations/:conversationId/events", async (ctx) => {
|
|
2481
|
+
await authenticateRequest(ctx, paths);
|
|
2482
|
+
const after = readInteger(ctx.query.after) ?? 0;
|
|
2483
|
+
const history = await conversations.listEvents(ctx.params.conversationId, after);
|
|
2484
|
+
ctx.respond = false;
|
|
2485
|
+
const response = ctx.res;
|
|
2486
|
+
response.statusCode = 200;
|
|
2487
|
+
response.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
2488
|
+
response.setHeader("cache-control", "no-store");
|
|
2489
|
+
response.setHeader("connection", "keep-alive");
|
|
2490
|
+
for (const event of history) {
|
|
2491
|
+
writeSseEvent(response, event);
|
|
2492
|
+
}
|
|
2493
|
+
const unsubscribe = conversations.subscribe(ctx.params.conversationId, (event) => {
|
|
2494
|
+
writeSseEvent(response, event);
|
|
2495
|
+
});
|
|
2496
|
+
const cleanup = () => {
|
|
2497
|
+
unsubscribe();
|
|
2498
|
+
response.end();
|
|
2499
|
+
};
|
|
2500
|
+
ctx.req.on("close", cleanup);
|
|
2501
|
+
});
|
|
2502
|
+
router.post("/api/v1/conversations/:conversationId/messages", async (ctx) => {
|
|
2503
|
+
await authenticateRequest(ctx, paths);
|
|
2504
|
+
const body = await readJsonBody(ctx.req);
|
|
2505
|
+
const content = readString3(body, "content") ?? readString3(body, "text") ?? readString3(body, "input") ?? "";
|
|
2506
|
+
const attachments = readMessageAttachments(body.attachments ?? body.blobs);
|
|
2507
|
+
if (!content && attachments.length === 0) {
|
|
2508
|
+
throw new LinkHttpError(400, "message_content_required", "message content is required");
|
|
2509
|
+
}
|
|
2510
|
+
ctx.status = 202;
|
|
2511
|
+
ctx.body = {
|
|
2512
|
+
ok: true,
|
|
2513
|
+
...await conversations.sendMessage({
|
|
2514
|
+
conversationId: ctx.params.conversationId,
|
|
2515
|
+
content,
|
|
2516
|
+
attachments,
|
|
2517
|
+
clientMessageId: readString3(body, "client_message_id") ?? readString3(body, "clientMessageId") ?? void 0,
|
|
2518
|
+
idempotencyKey: readHeader(ctx, "idempotency-key") ?? void 0
|
|
2519
|
+
})
|
|
2520
|
+
};
|
|
2521
|
+
});
|
|
2522
|
+
router.post("/api/v1/conversations/:conversationId/ack", async (ctx) => {
|
|
2523
|
+
await authenticateRequest(ctx, paths);
|
|
2524
|
+
ctx.body = { ok: true };
|
|
2525
|
+
});
|
|
2526
|
+
router.delete("/api/v1/conversations/:conversationId", async (ctx) => {
|
|
2527
|
+
await authenticateRequest(ctx, paths);
|
|
2528
|
+
ctx.body = {
|
|
2529
|
+
ok: true,
|
|
2530
|
+
...await conversations.deleteConversation(ctx.params.conversationId),
|
|
2531
|
+
blob_gc_completed: true
|
|
2532
|
+
};
|
|
2533
|
+
});
|
|
2534
|
+
router.post("/api/v1/conversations/:conversationId/blobs", async (ctx) => {
|
|
2535
|
+
await authenticateRequest(ctx, paths);
|
|
2536
|
+
const bytes = await readRawBody(ctx.req, MAX_BLOB_UPLOAD_BYTES);
|
|
2537
|
+
if (bytes.byteLength === 0) {
|
|
2538
|
+
throw new LinkHttpError(400, "blob_empty", "Blob body is empty");
|
|
2539
|
+
}
|
|
2540
|
+
const blob = await conversations.writeBlob(ctx.params.conversationId, {
|
|
2541
|
+
bytes,
|
|
2542
|
+
filename: readHeader(ctx, "x-filename") ?? void 0,
|
|
2543
|
+
mime: ctx.get("content-type") || void 0
|
|
2544
|
+
});
|
|
2545
|
+
ctx.status = 201;
|
|
2546
|
+
ctx.body = { ok: true, blob };
|
|
2547
|
+
});
|
|
2548
|
+
router.get("/api/v1/conversations/:conversationId/blobs/:blobId", async (ctx) => {
|
|
2549
|
+
await authenticateRequest(ctx, paths);
|
|
2550
|
+
const blob = await conversations.readBlob(ctx.params.conversationId, ctx.params.blobId);
|
|
2551
|
+
ctx.set("content-type", blob.mime);
|
|
2552
|
+
ctx.set("content-length", String(blob.size));
|
|
2553
|
+
ctx.set("cache-control", "private, max-age=86400");
|
|
2554
|
+
ctx.set("content-disposition", `inline; filename="${blob.filename.replaceAll('"', "")}"`);
|
|
2555
|
+
ctx.body = blob.bytes;
|
|
2556
|
+
});
|
|
1464
2557
|
router.post("/api/v1/runs", async (ctx) => {
|
|
1465
2558
|
await authenticateRequest(ctx, paths);
|
|
1466
2559
|
const body = await readJsonBody(ctx.req);
|
|
1467
|
-
const input =
|
|
2560
|
+
const input = readString3(body, "input");
|
|
1468
2561
|
if (!input) {
|
|
1469
2562
|
throw new LinkHttpError(400, "run_input_required", "input is required");
|
|
1470
2563
|
}
|
|
@@ -1472,7 +2565,7 @@ async function createApp(options = {}) {
|
|
|
1472
2565
|
ctx.body = await createHermesRun({
|
|
1473
2566
|
input,
|
|
1474
2567
|
conversation_history: readConversationHistory(body.conversation_history ?? body.conversationHistory),
|
|
1475
|
-
session_id:
|
|
2568
|
+
session_id: readString3(body, "session_id") ?? readString3(body, "sessionId") ?? void 0
|
|
1476
2569
|
});
|
|
1477
2570
|
});
|
|
1478
2571
|
router.get("/api/v1/runs/:runId/events", async (ctx) => {
|
|
@@ -1551,14 +2644,28 @@ async function createApp(options = {}) {
|
|
|
1551
2644
|
return app;
|
|
1552
2645
|
}
|
|
1553
2646
|
async function readJsonBody(request) {
|
|
2647
|
+
const raw = await readRawBody(request, MAX_JSON_BODY_BYTES);
|
|
2648
|
+
if (raw.byteLength === 0) {
|
|
2649
|
+
return {};
|
|
2650
|
+
}
|
|
2651
|
+
try {
|
|
2652
|
+
return JSON.parse(Buffer.from(raw).toString("utf8"));
|
|
2653
|
+
} catch {
|
|
2654
|
+
throw new LinkHttpError(400, "invalid_json", "Request body must be valid JSON");
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
async function readRawBody(request, maxBytes) {
|
|
1554
2658
|
const chunks = [];
|
|
2659
|
+
let totalBytes = 0;
|
|
1555
2660
|
for await (const chunk of request) {
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
2661
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2662
|
+
totalBytes += buffer.byteLength;
|
|
2663
|
+
if (totalBytes > maxBytes) {
|
|
2664
|
+
throw new LinkHttpError(413, "request_body_too_large", "Request body is too large");
|
|
2665
|
+
}
|
|
2666
|
+
chunks.push(buffer);
|
|
1560
2667
|
}
|
|
1561
|
-
return
|
|
2668
|
+
return Buffer.concat(chunks);
|
|
1562
2669
|
}
|
|
1563
2670
|
function readProfileName(body) {
|
|
1564
2671
|
if (typeof body.name !== "string") {
|
|
@@ -1597,7 +2704,7 @@ function readBearerToken(value) {
|
|
|
1597
2704
|
const token = trimmed.slice(7).trim();
|
|
1598
2705
|
return token || null;
|
|
1599
2706
|
}
|
|
1600
|
-
function
|
|
2707
|
+
function readString3(body, key) {
|
|
1601
2708
|
const value = body[key];
|
|
1602
2709
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
1603
2710
|
}
|
|
@@ -1609,6 +2716,25 @@ function readLimit(value) {
|
|
|
1609
2716
|
const parsed = Number.parseInt(raw, 10);
|
|
1610
2717
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
1611
2718
|
}
|
|
2719
|
+
function readInteger(value) {
|
|
2720
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
2721
|
+
if (typeof raw !== "string") {
|
|
2722
|
+
return void 0;
|
|
2723
|
+
}
|
|
2724
|
+
const parsed = Number.parseInt(raw, 10);
|
|
2725
|
+
return Number.isFinite(parsed) ? Math.max(0, parsed) : void 0;
|
|
2726
|
+
}
|
|
2727
|
+
function readHeader(ctx, name) {
|
|
2728
|
+
const value = ctx.get(name).trim();
|
|
2729
|
+
return value ? value : null;
|
|
2730
|
+
}
|
|
2731
|
+
function writeSseEvent(response, event) {
|
|
2732
|
+
response.write(`event: ${event.type}
|
|
2733
|
+
`);
|
|
2734
|
+
response.write(`data: ${JSON.stringify(event)}
|
|
2735
|
+
|
|
2736
|
+
`);
|
|
2737
|
+
}
|
|
1612
2738
|
function readConversationHistory(value) {
|
|
1613
2739
|
if (!Array.isArray(value)) {
|
|
1614
2740
|
return [];
|
|
@@ -1625,6 +2751,22 @@ function readConversationHistory(value) {
|
|
|
1625
2751
|
return { role, content };
|
|
1626
2752
|
}).filter((item) => Boolean(item));
|
|
1627
2753
|
}
|
|
2754
|
+
function readMessageAttachments(value) {
|
|
2755
|
+
if (!Array.isArray(value)) {
|
|
2756
|
+
return [];
|
|
2757
|
+
}
|
|
2758
|
+
return value.map((item) => {
|
|
2759
|
+
if (typeof item === "string" && item.trim()) {
|
|
2760
|
+
return { blob_id: item.trim() };
|
|
2761
|
+
}
|
|
2762
|
+
if (typeof item !== "object" || item === null) {
|
|
2763
|
+
return null;
|
|
2764
|
+
}
|
|
2765
|
+
const record = item;
|
|
2766
|
+
const blobId = typeof record.blob_id === "string" ? record.blob_id.trim() : typeof record.blobId === "string" ? record.blobId.trim() : "";
|
|
2767
|
+
return blobId ? { blob_id: blobId } : null;
|
|
2768
|
+
}).filter((item) => Boolean(item));
|
|
2769
|
+
}
|
|
1628
2770
|
function routeObjects(urls) {
|
|
1629
2771
|
return urls.map((url) => ({
|
|
1630
2772
|
kind: url.includes("/api/v1/relay/links/") ? "relay" : "lan",
|
|
@@ -1649,4 +2791,4 @@ export {
|
|
|
1649
2791
|
getLinkLogFile,
|
|
1650
2792
|
createApp
|
|
1651
2793
|
};
|
|
1652
|
-
//# sourceMappingURL=chunk-
|
|
2794
|
+
//# sourceMappingURL=chunk-VCQJ5DSN.js.map
|