@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.5";
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/http/errors.ts
95
- var LinkHttpError = class extends Error {
96
- constructor(status, code, message) {
97
- super(message);
98
- this.status = status;
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(path7, init, options) {
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}${path7}`, {
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 mkdir3, readdir, rename as rename2, rm as rm2, stat } from "fs/promises";
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 path4 from "path";
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 = path4.join(os3.homedir(), ".hermes", "profiles");
328
- const entries = await readdir(profilesDir, { withFileTypes: true }).catch((error) => {
329
- if (isNodeError3(error, "ENOENT")) {
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 stat(profile.path).then((value) => value.isDirectory()).catch((error) => {
353
- if (isNodeError3(error, "ENOENT")) {
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 mkdir3(profile.path, { recursive: true, mode: 448 });
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 mkdir3(profile.path, { recursive: true, mode: 448 });
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 rm2(resolveHermesProfileDir(name), { recursive: true, force: true });
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 stat(targetPath).then(() => true).catch((error) => {
423
- if (isNodeError3(error, "ENOENT")) {
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 isNodeError3(error, code) {
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 randomUUID2, sign } from "crypto";
435
- import { mkdir as mkdir4, chmod } from "fs/promises";
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 mkdir4(paths.homeDir, { recursive: true, mode: 448 });
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_${randomUUID2().replaceAll("-", "")}`,
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 path5 from "path";
497
- import { rm as rm3 } from "fs/promises";
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 randomUUID3, timingSafeEqual, createHash } from "crypto";
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_${randomUUID3().replaceAll("-", "")}`,
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 createHash("sha256").update(value).digest("hex");
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 = readErrorMessage(payload) ?? `Relay request failed with HTTP ${response.status}`;
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 readErrorMessage(payload) {
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 rm3(pairingClaimPath(sessionId, paths), { force: true }).catch(() => void 0);
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, path7, body) {
965
- const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path7}`, {
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, path7, token, body) {
976
- const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path7}`, {
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 = readErrorMessage2(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
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 readErrorMessage2(payload) {
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 path5.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
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 = decodeJson(encodedHeader);
1035
- const payload = decodeJson(encodedPayload);
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 decodeJson(value) {
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 mkdir5, open as open2, readFile as readFile3, rename as rename3, rm as rm4, stat as stat2 } from "fs/promises";
1087
- import path6 from "path";
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 mkdir5(this.paths.logsDir, { recursive: true, mode: 448 });
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 appendFile(this.filePath, line, { mode: 384 });
2120
+ await appendFile2(this.filePath, line, { mode: 384 });
1140
2121
  }
1141
2122
  async rotateIfNeeded(nextBytes) {
1142
- const current = await stat2(this.filePath).catch(() => null);
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 rm4(this.filePath, { force: true }).catch(() => void 0);
2128
+ await rm5(this.filePath, { force: true }).catch(() => void 0);
1148
2129
  return;
1149
2130
  }
1150
- await rm4(rotatedLogFile(this.filePath, this.maxFiles), { force: true }).catch(() => void 0);
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 path6.join(paths.logsDir, fileName);
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 stat2(filePath).catch(() => null);
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 readFile3(filePath, "utf8").catch(() => null);
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 rm4(to, { force: true }).catch(() => void 0);
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 = readString2(body, "session_id") ?? readString2(body, "sessionId");
1356
- const claimToken = readString2(body, "claim_token") ?? readString2(body, "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: readString2(body, "device_label") ?? readString2(body, "deviceLabel") ?? "HermesPilot App",
1364
- devicePlatform: readString2(body, "device_platform") ?? readString2(body, "devicePlatform") ?? "unknown",
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 = readString2(body, "refresh_token") ?? readString2(body, "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 = readString2(body, "refresh_token") ?? readString2(body, "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 = readString2(body, "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: readString2(body, "session_id") ?? readString2(body, "sessionId") ?? void 0
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
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1557
- }
1558
- if (chunks.length === 0) {
1559
- return {};
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 JSON.parse(Buffer.concat(chunks).toString("utf8"));
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 readString2(body, key) {
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-E54NSFGF.js.map
2794
+ //# sourceMappingURL=chunk-VCQJ5DSN.js.map