@hermespilot/link 0.1.4 → 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.4";
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(path6, 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(path6, 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}${path6}`, {
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(),
@@ -492,8 +1473,12 @@ function getIdentityStatus(identity) {
492
1473
  };
493
1474
  }
494
1475
 
1476
+ // src/pairing/pairing.ts
1477
+ import path6 from "path";
1478
+ import { rm as rm4 } from "fs/promises";
1479
+
495
1480
  // src/security/devices.ts
496
- 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";
497
1482
  var ACCESS_TOKEN_TTL_MS = 15 * 60 * 1e3;
498
1483
  var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
499
1484
  async function createDeviceSession(input, paths = resolveRuntimePaths()) {
@@ -502,7 +1487,7 @@ async function createDeviceSession(input, paths = resolveRuntimePaths()) {
502
1487
  const accessToken = randomToken("hpat_");
503
1488
  const refreshToken = randomToken("hprt_");
504
1489
  const device = {
505
- id: `dev_${randomUUID3().replaceAll("-", "")}`,
1490
+ id: `dev_${randomUUID4().replaceAll("-", "")}`,
506
1491
  label: input.label,
507
1492
  platform: input.platform,
508
1493
  scope: "admin",
@@ -591,7 +1576,7 @@ function randomToken(prefix) {
591
1576
  return `${prefix}${randomBytes2(24).toString("base64url")}`;
592
1577
  }
593
1578
  function sha256(value) {
594
- return createHash("sha256").update(value).digest("hex");
1579
+ return createHash2("sha256").update(value).digest("hex");
595
1580
  }
596
1581
  function safeEqual(left, right) {
597
1582
  const leftBytes = Buffer.from(left);
@@ -650,7 +1635,7 @@ async function postJson(fetcher, url, token, body) {
650
1635
  });
651
1636
  const payload = await response.json().catch(() => null);
652
1637
  if (!response.ok) {
653
- const message = readErrorMessage(payload) ?? `Relay request failed with HTTP ${response.status}`;
1638
+ const message = readErrorMessage2(payload) ?? `Relay request failed with HTTP ${response.status}`;
654
1639
  throw new Error(message);
655
1640
  }
656
1641
  if (!payload) {
@@ -658,7 +1643,7 @@ async function postJson(fetcher, url, token, body) {
658
1643
  }
659
1644
  return payload;
660
1645
  }
661
- function readErrorMessage(payload) {
1646
+ function readErrorMessage2(payload) {
662
1647
  if (typeof payload !== "object" || payload === null) {
663
1648
  return null;
664
1649
  }
@@ -887,6 +1872,33 @@ async function preparePairing(paths = resolveRuntimePaths()) {
887
1872
  qrPayload
888
1873
  };
889
1874
  }
1875
+ async function recordPairingClaim(input, paths = resolveRuntimePaths()) {
1876
+ const record = {
1877
+ session_id: input.sessionId,
1878
+ device_id: input.deviceId,
1879
+ device_label: input.deviceLabel,
1880
+ device_platform: input.devicePlatform,
1881
+ claimed_at: (/* @__PURE__ */ new Date()).toISOString()
1882
+ };
1883
+ await writeJsonFile(pairingClaimPath(input.sessionId, paths), record);
1884
+ return record;
1885
+ }
1886
+ async function readPairingClaim(sessionId, paths = resolveRuntimePaths()) {
1887
+ const record = await readJsonFile(pairingClaimPath(sessionId, paths));
1888
+ if (!record || record.session_id !== sessionId || typeof record.device_id !== "string") {
1889
+ return null;
1890
+ }
1891
+ return {
1892
+ session_id: record.session_id,
1893
+ device_id: record.device_id,
1894
+ device_label: typeof record.device_label === "string" ? record.device_label : "",
1895
+ device_platform: typeof record.device_platform === "string" ? record.device_platform : "unknown",
1896
+ claimed_at: typeof record.claimed_at === "string" ? record.claimed_at : ""
1897
+ };
1898
+ }
1899
+ async function clearPairingClaim(sessionId, paths = resolveRuntimePaths()) {
1900
+ await rm4(pairingClaimPath(sessionId, paths), { force: true }).catch(() => void 0);
1901
+ }
890
1902
  async function claimPairing(input) {
891
1903
  const paths = input.paths ?? resolveRuntimePaths();
892
1904
  const [identity, config] = await Promise.all([loadRequiredIdentity(paths), loadConfig(paths)]);
@@ -930,8 +1942,8 @@ async function loadRequiredIdentity(paths) {
930
1942
  }
931
1943
  return identity;
932
1944
  }
933
- async function postServerJson(serverBaseUrl, path6, body) {
934
- const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path6}`, {
1945
+ async function postServerJson(serverBaseUrl, path8, body) {
1946
+ const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path8}`, {
935
1947
  method: "POST",
936
1948
  headers: {
937
1949
  accept: "application/json",
@@ -941,8 +1953,8 @@ async function postServerJson(serverBaseUrl, path6, body) {
941
1953
  });
942
1954
  return readJsonResponse2(response);
943
1955
  }
944
- async function patchServerJson(serverBaseUrl, path6, token, body) {
945
- const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path6}`, {
1956
+ async function patchServerJson(serverBaseUrl, path8, token, body) {
1957
+ const response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path8}`, {
946
1958
  method: "PATCH",
947
1959
  headers: {
948
1960
  accept: "application/json",
@@ -956,12 +1968,12 @@ async function patchServerJson(serverBaseUrl, path6, token, body) {
956
1968
  async function readJsonResponse2(response) {
957
1969
  const payload = await response.json().catch(() => null);
958
1970
  if (!response.ok || !payload) {
959
- 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}`;
960
1972
  throw new LinkHttpError(response.status, "server_request_failed", message);
961
1973
  }
962
1974
  return payload;
963
1975
  }
964
- function readErrorMessage2(payload) {
1976
+ function readErrorMessage3(payload) {
965
1977
  if (typeof payload !== "object" || payload === null) {
966
1978
  return null;
967
1979
  }
@@ -975,6 +1987,9 @@ function readErrorMessage2(payload) {
975
1987
  function defaultDisplayName() {
976
1988
  return `Hermes Link ${process.platform}`;
977
1989
  }
1990
+ function pairingClaimPath(sessionId, paths) {
1991
+ return path6.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
1992
+ }
978
1993
  function qrPreferredUrls(routes) {
979
1994
  return routes.preferredUrls.filter((url) => !url.includes("/api/v1/relay/links/")).slice(0, 1);
980
1995
  }
@@ -997,8 +2012,8 @@ async function verifyAppConnectToken(token, options) {
997
2012
  throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token is malformed");
998
2013
  }
999
2014
  const [encodedHeader, encodedPayload, encodedSignature] = segments;
1000
- const header = decodeJson(encodedHeader);
1001
- const payload = decodeJson(encodedPayload);
2015
+ const header = decodeJson2(encodedHeader);
2016
+ const payload = decodeJson2(encodedPayload);
1002
2017
  if (header.alg !== "ES256" || header.typ !== "JWT") {
1003
2018
  throw new LinkHttpError(401, "app_connect_token_invalid", "App connect token algorithm is unsupported");
1004
2019
  }
@@ -1040,7 +2055,7 @@ async function getJwks(config, fetcher) {
1040
2055
  };
1041
2056
  return keys;
1042
2057
  }
1043
- function decodeJson(value) {
2058
+ function decodeJson2(value) {
1044
2059
  return JSON.parse(Buffer.from(base64UrlToBase64(value), "base64").toString("utf8"));
1045
2060
  }
1046
2061
  function base64UrlToBase64(value) {
@@ -1049,8 +2064,8 @@ function base64UrlToBase64(value) {
1049
2064
  }
1050
2065
 
1051
2066
  // src/runtime/logger.ts
1052
- import { appendFile, mkdir as mkdir5, open as open2, readFile as readFile3, rename as rename3, rm as rm3, stat as stat2 } from "fs/promises";
1053
- import path5 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";
1054
2069
  var DEFAULT_LOG_FILE = "hermeslink.log";
1055
2070
  var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
1056
2071
  var DEFAULT_MAX_FILES = 5;
@@ -1098,22 +2113,22 @@ var FileLogger = class {
1098
2113
  return this.queue;
1099
2114
  }
1100
2115
  async appendEntry(entry) {
1101
- await mkdir5(this.paths.logsDir, { recursive: true, mode: 448 });
2116
+ await mkdir6(this.paths.logsDir, { recursive: true, mode: 448 });
1102
2117
  const line = `${JSON.stringify(entry)}
1103
2118
  `;
1104
2119
  await this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
1105
- await appendFile(this.filePath, line, { mode: 384 });
2120
+ await appendFile2(this.filePath, line, { mode: 384 });
1106
2121
  }
1107
2122
  async rotateIfNeeded(nextBytes) {
1108
- const current = await stat2(this.filePath).catch(() => null);
2123
+ const current = await stat3(this.filePath).catch(() => null);
1109
2124
  if (!current || current.size === 0 || current.size + nextBytes <= this.maxFileBytes) {
1110
2125
  return;
1111
2126
  }
1112
2127
  if (this.maxFiles === 0) {
1113
- await rm3(this.filePath, { force: true }).catch(() => void 0);
2128
+ await rm5(this.filePath, { force: true }).catch(() => void 0);
1114
2129
  return;
1115
2130
  }
1116
- await rm3(rotatedLogFile(this.filePath, this.maxFiles), { force: true }).catch(() => void 0);
2131
+ await rm5(rotatedLogFile(this.filePath, this.maxFiles), { force: true }).catch(() => void 0);
1117
2132
  for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
1118
2133
  await moveIfExists(rotatedLogFile(this.filePath, index), rotatedLogFile(this.filePath, index + 1));
1119
2134
  }
@@ -1124,7 +2139,7 @@ function createFileLogger(options = {}) {
1124
2139
  return new FileLogger(options);
1125
2140
  }
1126
2141
  function getLinkLogFile(paths = resolveRuntimePaths(), fileName = DEFAULT_LOG_FILE) {
1127
- return path5.join(paths.logsDir, fileName);
2142
+ return path7.join(paths.logsDir, fileName);
1128
2143
  }
1129
2144
  async function readRecentLogEntries(options = {}) {
1130
2145
  const paths = options.paths ?? resolveRuntimePaths();
@@ -1219,12 +2234,12 @@ function isLogLevel(value) {
1219
2234
  return value === "debug" || value === "info" || value === "warn" || value === "error";
1220
2235
  }
1221
2236
  async function readTail(filePath, maxBytes) {
1222
- const info = await stat2(filePath).catch(() => null);
2237
+ const info = await stat3(filePath).catch(() => null);
1223
2238
  if (!info || info.size <= 0) {
1224
2239
  return null;
1225
2240
  }
1226
2241
  if (info.size <= maxBytes) {
1227
- return await readFile3(filePath, "utf8").catch(() => null);
2242
+ return await readFile4(filePath, "utf8").catch(() => null);
1228
2243
  }
1229
2244
  const handle = await open2(filePath, "r").catch(() => null);
1230
2245
  if (!handle) {
@@ -1240,7 +2255,7 @@ async function readTail(filePath, maxBytes) {
1240
2255
  }
1241
2256
  }
1242
2257
  async function moveIfExists(from, to) {
1243
- await rm3(to, { force: true }).catch(() => void 0);
2258
+ await rm5(to, { force: true }).catch(() => void 0);
1244
2259
  await rename3(from, to).catch((error) => {
1245
2260
  if (error.code !== "ENOENT") {
1246
2261
  throw error;
@@ -1252,9 +2267,12 @@ function rotatedLogFile(filePath, index) {
1252
2267
  }
1253
2268
 
1254
2269
  // src/http/app.ts
2270
+ var MAX_JSON_BODY_BYTES = 1024 * 1024;
2271
+ var MAX_BLOB_UPLOAD_BYTES = 50 * 1024 * 1024;
1255
2272
  async function createApp(options = {}) {
1256
2273
  const paths = options.paths ?? resolveRuntimePaths();
1257
2274
  const logger = options.logger ?? createFileLogger({ paths });
2275
+ const conversations = new ConversationService(paths, logger);
1258
2276
  const app = new Koa();
1259
2277
  const router = new Router();
1260
2278
  app.use(async (ctx, next) => {
@@ -1312,22 +2330,25 @@ async function createApp(options = {}) {
1312
2330
  sse: true,
1313
2331
  relay: true,
1314
2332
  profiles: true,
1315
- logs: true
2333
+ logs: true,
2334
+ conversations: true,
2335
+ conversation_delete: true,
2336
+ blobs: true
1316
2337
  }
1317
2338
  };
1318
2339
  });
1319
2340
  router.post("/api/v1/pairing/claim", async (ctx) => {
1320
2341
  const body = await readJsonBody(ctx.req);
1321
- const sessionId = readString2(body, "session_id") ?? readString2(body, "sessionId");
1322
- 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");
1323
2344
  if (!sessionId || !claimToken) {
1324
2345
  throw new LinkHttpError(400, "pairing_claim_invalid", "session_id and claim_token are required");
1325
2346
  }
1326
2347
  const claimed = await claimPairing({
1327
2348
  sessionId,
1328
2349
  claimToken,
1329
- deviceLabel: readString2(body, "device_label") ?? readString2(body, "deviceLabel") ?? "HermesPilot App",
1330
- 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",
1331
2352
  paths
1332
2353
  });
1333
2354
  ctx.body = claimed;
@@ -1335,12 +2356,24 @@ async function createApp(options = {}) {
1335
2356
  device_id: claimed.device.device_id,
1336
2357
  device_platform: claimed.device.platform
1337
2358
  });
1338
- if (options.onPairingClaimed) {
1339
- const timer = setTimeout(() => {
1340
- void options.onPairingClaimed?.();
1341
- }, 250);
1342
- timer.unref?.();
1343
- }
2359
+ const timer = setTimeout(() => {
2360
+ void recordPairingClaim(
2361
+ {
2362
+ sessionId,
2363
+ deviceId: claimed.device.device_id,
2364
+ deviceLabel: claimed.device.label,
2365
+ devicePlatform: claimed.device.platform
2366
+ },
2367
+ paths
2368
+ ).catch((error) => {
2369
+ void logger.warn("pairing_claim_record_failed", {
2370
+ session_id: sessionId,
2371
+ error: error instanceof Error ? error.message : String(error)
2372
+ });
2373
+ });
2374
+ void options.onPairingClaimed?.();
2375
+ }, 250);
2376
+ timer.unref?.();
1344
2377
  });
1345
2378
  router.get("/api/v1/auth/me", async (ctx) => {
1346
2379
  const auth = await authenticateRequest(ctx, paths);
@@ -1363,7 +2396,7 @@ async function createApp(options = {}) {
1363
2396
  });
1364
2397
  router.post("/api/v1/auth/refresh", async (ctx) => {
1365
2398
  const body = await readJsonBody(ctx.req);
1366
- const refreshToken = readString2(body, "refresh_token") ?? readString2(body, "refreshToken");
2399
+ const refreshToken = readString3(body, "refresh_token") ?? readString3(body, "refreshToken");
1367
2400
  if (!refreshToken) {
1368
2401
  throw new LinkHttpError(400, "refresh_token_required", "refresh_token is required");
1369
2402
  }
@@ -1383,7 +2416,7 @@ async function createApp(options = {}) {
1383
2416
  });
1384
2417
  router.post("/api/v1/auth/logout", async (ctx) => {
1385
2418
  const body = await readJsonBody(ctx.req);
1386
- const refreshToken = readString2(body, "refresh_token") ?? readString2(body, "refreshToken");
2419
+ const refreshToken = readString3(body, "refresh_token") ?? readString3(body, "refreshToken");
1387
2420
  if (refreshToken) {
1388
2421
  await revokeDeviceRefreshToken(refreshToken, paths);
1389
2422
  }
@@ -1415,10 +2448,116 @@ async function createApp(options = {}) {
1415
2448
  await authenticateRequest(ctx, paths);
1416
2449
  ctx.body = await listHermesModels();
1417
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
+ });
1418
2557
  router.post("/api/v1/runs", async (ctx) => {
1419
2558
  await authenticateRequest(ctx, paths);
1420
2559
  const body = await readJsonBody(ctx.req);
1421
- const input = readString2(body, "input");
2560
+ const input = readString3(body, "input");
1422
2561
  if (!input) {
1423
2562
  throw new LinkHttpError(400, "run_input_required", "input is required");
1424
2563
  }
@@ -1426,7 +2565,7 @@ async function createApp(options = {}) {
1426
2565
  ctx.body = await createHermesRun({
1427
2566
  input,
1428
2567
  conversation_history: readConversationHistory(body.conversation_history ?? body.conversationHistory),
1429
- session_id: readString2(body, "session_id") ?? readString2(body, "sessionId") ?? void 0
2568
+ session_id: readString3(body, "session_id") ?? readString3(body, "sessionId") ?? void 0
1430
2569
  });
1431
2570
  });
1432
2571
  router.get("/api/v1/runs/:runId/events", async (ctx) => {
@@ -1505,14 +2644,28 @@ async function createApp(options = {}) {
1505
2644
  return app;
1506
2645
  }
1507
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) {
1508
2658
  const chunks = [];
2659
+ let totalBytes = 0;
1509
2660
  for await (const chunk of request) {
1510
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1511
- }
1512
- if (chunks.length === 0) {
1513
- 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);
1514
2667
  }
1515
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
2668
+ return Buffer.concat(chunks);
1516
2669
  }
1517
2670
  function readProfileName(body) {
1518
2671
  if (typeof body.name !== "string") {
@@ -1551,7 +2704,7 @@ function readBearerToken(value) {
1551
2704
  const token = trimmed.slice(7).trim();
1552
2705
  return token || null;
1553
2706
  }
1554
- function readString2(body, key) {
2707
+ function readString3(body, key) {
1555
2708
  const value = body[key];
1556
2709
  return typeof value === "string" && value.trim() ? value.trim() : null;
1557
2710
  }
@@ -1563,6 +2716,25 @@ function readLimit(value) {
1563
2716
  const parsed = Number.parseInt(raw, 10);
1564
2717
  return Number.isFinite(parsed) ? parsed : void 0;
1565
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
+ }
1566
2738
  function readConversationHistory(value) {
1567
2739
  if (!Array.isArray(value)) {
1568
2740
  return [];
@@ -1579,6 +2751,22 @@ function readConversationHistory(value) {
1579
2751
  return { role, content };
1580
2752
  }).filter((item) => Boolean(item));
1581
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
+ }
1582
2770
  function routeObjects(urls) {
1583
2771
  return urls.map((url) => ({
1584
2772
  kind: url.includes("/api/v1/relay/links/") ? "relay" : "lan",
@@ -1597,8 +2785,10 @@ export {
1597
2785
  ensureIdentity,
1598
2786
  getIdentityStatus,
1599
2787
  preparePairing,
2788
+ readPairingClaim,
2789
+ clearPairingClaim,
1600
2790
  createFileLogger,
1601
2791
  getLinkLogFile,
1602
2792
  createApp
1603
2793
  };
1604
- //# sourceMappingURL=chunk-T35GPRKF.js.map
2794
+ //# sourceMappingURL=chunk-VCQJ5DSN.js.map