@hermespilot/link 0.3.1 → 0.3.3

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.
@@ -353,7 +353,7 @@ async function readLinkUsageStatistics(paths, filter = {}) {
353
353
  FROM run_usage_facts
354
354
  ${where.sql}
355
355
  GROUP BY COALESCE(NULLIF(model, ''), 'unknown'), provider
356
- HAVING total_tokens > 0
356
+ HAVING SUM(total_tokens) > 0
357
357
  ORDER BY total_tokens DESC, run_count DESC, model ASC
358
358
  LIMIT 12
359
359
  `).all(...where.params);
@@ -379,7 +379,7 @@ async function readLinkUsageStatistics(paths, filter = {}) {
379
379
  NULLIF(profile_uid, ''),
380
380
  'unknown'
381
381
  )
382
- HAVING total_tokens > 0
382
+ HAVING SUM(total_tokens) > 0
383
383
  ORDER BY total_tokens DESC, run_count DESC, profile ASC
384
384
  LIMIT 12
385
385
  `).all(...where.params);
@@ -3724,7 +3724,7 @@ import os2 from "os";
3724
3724
  import path5 from "path";
3725
3725
 
3726
3726
  // src/constants.ts
3727
- var LINK_VERSION = "0.3.1";
3727
+ var LINK_VERSION = "0.3.3";
3728
3728
  var LINK_COMMAND = "hermeslink";
3729
3729
  var LINK_DEFAULT_PORT = 52379;
3730
3730
  var LINK_RUNTIME_DIR_NAME = ".hermeslink";
@@ -4091,7 +4091,8 @@ import { execFile } from "child_process";
4091
4091
  import { promisify } from "util";
4092
4092
  var execFileAsync = promisify(execFile);
4093
4093
  async function deleteHermesSession(sessionId, profileName = "default") {
4094
- if (!sessionId.trim()) {
4094
+ const normalizedSessionId = sessionId.trim();
4095
+ if (!normalizedSessionId) {
4095
4096
  throw new LinkHttpError(
4096
4097
  400,
4097
4098
  "hermes_session_id_required",
@@ -4099,15 +4100,31 @@ async function deleteHermesSession(sessionId, profileName = "default") {
4099
4100
  );
4100
4101
  }
4101
4102
  try {
4102
- await execFileAsync(
4103
+ const output = await execFileAsync(
4103
4104
  resolveHermesBin(),
4104
- [...profileArgs(profileName), "sessions", "delete", sessionId, "--yes"],
4105
+ [
4106
+ ...profileArgs(profileName),
4107
+ "sessions",
4108
+ "delete",
4109
+ normalizedSessionId,
4110
+ "--yes"
4111
+ ],
4105
4112
  {
4106
4113
  timeout: 1e4,
4107
4114
  windowsHide: true
4108
4115
  }
4109
4116
  );
4117
+ return {
4118
+ session_id: normalizedSessionId,
4119
+ status: readSessionDeleteStatus(output.stdout, output.stderr)
4120
+ };
4110
4121
  } catch (error) {
4122
+ if (isSessionNotFoundOutput(readExecErrorOutput(error))) {
4123
+ return {
4124
+ session_id: normalizedSessionId,
4125
+ status: "not_found"
4126
+ };
4127
+ }
4111
4128
  throw new LinkHttpError(
4112
4129
  502,
4113
4130
  "hermes_session_delete_failed",
@@ -4158,6 +4175,29 @@ async function renameHermesSession(sessionId, title, profileName = "default") {
4158
4175
  function resolveHermesBin() {
4159
4176
  return process.env.HERMES_BIN?.trim() || "hermes";
4160
4177
  }
4178
+ function readSessionDeleteStatus(stdout, stderr) {
4179
+ const output = `${stdout.toString()}
4180
+ ${stderr.toString()}`;
4181
+ if (isSessionNotFoundOutput(output)) {
4182
+ return "not_found";
4183
+ }
4184
+ if (/deleted session\b/i.test(output)) {
4185
+ return "deleted";
4186
+ }
4187
+ return "unknown";
4188
+ }
4189
+ function isSessionNotFoundOutput(output) {
4190
+ return /\bsession\b[\s\S]*\bnot found\b/i.test(output);
4191
+ }
4192
+ function readExecErrorOutput(error) {
4193
+ if (typeof error !== "object" || error === null) {
4194
+ return "";
4195
+ }
4196
+ const stdout = "stdout" in error && error.stdout != null ? String(error.stdout) : "";
4197
+ const stderr = "stderr" in error && error.stderr != null ? String(error.stderr) : "";
4198
+ return `${stdout}
4199
+ ${stderr}`;
4200
+ }
4161
4201
  function profileArgs(profileName) {
4162
4202
  const normalized = profileName.trim() || "default";
4163
4203
  return normalized === "default" ? [] : ["-p", normalized];
@@ -6327,6 +6367,51 @@ function safePathSegment(value, fallback) {
6327
6367
  return safe.length > 0 ? safe.slice(0, 120) : fallback;
6328
6368
  }
6329
6369
 
6370
+ // src/conversations/conversation-session-ids.ts
6371
+ function normalizeHermesSessionIds(values) {
6372
+ const seen = /* @__PURE__ */ new Set();
6373
+ for (const value of values) {
6374
+ const sessionId = value?.trim();
6375
+ if (!sessionId || seen.has(sessionId)) {
6376
+ continue;
6377
+ }
6378
+ seen.add(sessionId);
6379
+ }
6380
+ return [...seen];
6381
+ }
6382
+ function addHermesSessionIdToManifest(manifest, sessionId) {
6383
+ const normalizedSessionId = sessionId.trim();
6384
+ if (!normalizedSessionId) {
6385
+ return manifest;
6386
+ }
6387
+ const hermesSessionIds = normalizeHermesSessionIds([
6388
+ ...manifest.hermes_session_ids ?? [],
6389
+ manifest.hermes_session_id,
6390
+ normalizedSessionId
6391
+ ]);
6392
+ if (manifest.hermes_session_id === normalizedSessionId && arraysEqual(manifest.hermes_session_ids ?? [], hermesSessionIds)) {
6393
+ return manifest;
6394
+ }
6395
+ return {
6396
+ ...manifest,
6397
+ hermes_session_id: normalizedSessionId,
6398
+ hermes_session_ids: hermesSessionIds
6399
+ };
6400
+ }
6401
+ function collectHermesSessionIds(manifest, snapshot) {
6402
+ return normalizeHermesSessionIds([
6403
+ manifest.hermes_session_id,
6404
+ ...manifest.hermes_session_ids ?? [],
6405
+ ...snapshot.runs.map((run) => run.hermes_session_id)
6406
+ ]);
6407
+ }
6408
+ function arraysEqual(left, right) {
6409
+ if (left.length !== right.length) {
6410
+ return false;
6411
+ }
6412
+ return left.every((item, index) => item === right[index]);
6413
+ }
6414
+
6330
6415
  // src/conversations/conversation-maintenance.ts
6331
6416
  var MAX_UPLOADED_BLOB_BYTES = 50 * 1024 * 1024;
6332
6417
  var ConversationMaintenanceCoordinator = class {
@@ -6447,10 +6532,18 @@ var ConversationMaintenanceCoordinator = class {
6447
6532
  ...collectBlobIds(snapshot),
6448
6533
  ...await this.listConversationBlobIds(conversationId)
6449
6534
  ]);
6450
- await deleteHermesSession(
6451
- manifest.hermes_session_id,
6535
+ const hermesSessionIds = collectHermesSessionIds(manifest, snapshot);
6536
+ const hermesDeleteResults = await this.deleteHermesSessions(
6537
+ hermesSessionIds,
6452
6538
  manifest.profile_name_snapshot ?? manifest.profile ?? "default"
6453
6539
  );
6540
+ if (snapshot.runs.length > 0 && hermesDeleteResults.every((result) => result.status === "not_found")) {
6541
+ throw new LinkHttpError(
6542
+ 502,
6543
+ "hermes_session_delete_not_confirmed",
6544
+ "Hermes session deletion was not confirmed"
6545
+ );
6546
+ }
6454
6547
  const deletedAt = (/* @__PURE__ */ new Date()).toISOString();
6455
6548
  const stats = buildConversationStats(
6456
6549
  {
@@ -6464,6 +6557,7 @@ var ConversationMaintenanceCoordinator = class {
6464
6557
  const next = {
6465
6558
  ...manifest,
6466
6559
  status: "deleted_soft",
6560
+ hermes_session_ids: hermesSessionIds,
6467
6561
  updated_at: deletedAt,
6468
6562
  deleted_at: deletedAt,
6469
6563
  stats
@@ -6473,7 +6567,9 @@ var ConversationMaintenanceCoordinator = class {
6473
6567
  type: "conversation.deleted",
6474
6568
  payload: {
6475
6569
  deleted_at: deletedAt,
6476
- hermes_session_id: manifest.hermes_session_id
6570
+ hermes_session_id: manifest.hermes_session_id,
6571
+ hermes_session_ids: hermesSessionIds,
6572
+ hermes_delete_results: hermesDeleteResults
6477
6573
  }
6478
6574
  });
6479
6575
  await this.deps.store.writeSnapshot(conversationId, emptySnapshot2());
@@ -6491,9 +6587,17 @@ var ConversationMaintenanceCoordinator = class {
6491
6587
  return {
6492
6588
  conversation_id: conversationId,
6493
6589
  hermes_deleted: true,
6590
+ hermes_session_ids: hermesSessionIds,
6494
6591
  deleted_at: deletedAt
6495
6592
  };
6496
6593
  }
6594
+ async deleteHermesSessions(sessionIds, profileName) {
6595
+ const results = [];
6596
+ for (const sessionId of sessionIds) {
6597
+ results.push(await deleteHermesSession(sessionId, profileName));
6598
+ }
6599
+ return results;
6600
+ }
6497
6601
  async pruneConversationBlobReferences(conversationId, blobIds) {
6498
6602
  for (const blobId of blobIds) {
6499
6603
  try {
@@ -7640,8 +7744,12 @@ var ConversationOrchestrationCoordinator = class {
7640
7744
  const resetAt = (/* @__PURE__ */ new Date()).toISOString();
7641
7745
  const previousSessionId = input.manifest.hermes_session_id;
7642
7746
  const nextSessionId = freshHermesSessionId(input.manifest.id);
7747
+ const nextManifest = addHermesSessionIdToManifest(
7748
+ input.manifest,
7749
+ nextSessionId
7750
+ );
7643
7751
  await this.deps.store.writeManifest({
7644
- ...input.manifest,
7752
+ ...nextManifest,
7645
7753
  hermes_session_id: nextSessionId,
7646
7754
  command_state: {
7647
7755
  ...input.manifest.command_state,
@@ -9787,9 +9895,11 @@ var ConversationRunLifecycle = class {
9787
9895
  this.deps.paths,
9788
9896
  run.profile
9789
9897
  ).catch(() => void 0) ?? run.hermes_session_id;
9790
- await this.updateRun(conversationId, runId, {
9791
- hermes_session_id: hermesSessionId
9792
- });
9898
+ await this.rememberRunHermesSessionId(
9899
+ conversationId,
9900
+ runId,
9901
+ hermesSessionId
9902
+ );
9793
9903
  const conversationHistory = await buildConversationHistory({
9794
9904
  paths: this.deps.paths,
9795
9905
  profileName: run.profile,
@@ -9859,9 +9969,11 @@ var ConversationRunLifecycle = class {
9859
9969
  );
9860
9970
  const responseSessionId = response.headers.get("x-hermes-session-id")?.trim();
9861
9971
  if (responseSessionId) {
9862
- await this.updateRun(conversationId, runId, {
9863
- hermes_session_id: responseSessionId
9864
- });
9972
+ await this.rememberRunHermesSessionId(
9973
+ conversationId,
9974
+ runId,
9975
+ responseSessionId
9976
+ );
9865
9977
  }
9866
9978
  for await (const rawEvent of parseSseResponse(response)) {
9867
9979
  if (controller.signal.aborted) {
@@ -10047,6 +10159,29 @@ ${attachmentLines.join("\n")}`
10047
10159
  Object.assign(run, patch);
10048
10160
  await this.deps.writeSnapshot(conversationId, snapshot);
10049
10161
  }
10162
+ async rememberRunHermesSessionId(conversationId, runId, sessionId) {
10163
+ const normalizedSessionId = sessionId.trim();
10164
+ if (!normalizedSessionId) {
10165
+ return;
10166
+ }
10167
+ return this.deps.withConversationLock(conversationId, async () => {
10168
+ const snapshot = await this.deps.readSnapshot(conversationId);
10169
+ const run = snapshot.runs.find((item) => item.id === runId);
10170
+ if (!run) {
10171
+ return;
10172
+ }
10173
+ run.hermes_session_id = normalizedSessionId;
10174
+ await this.deps.writeSnapshot(conversationId, snapshot);
10175
+ const manifest = await this.deps.readActiveManifest(conversationId);
10176
+ const nextManifest = addHermesSessionIdToManifest(
10177
+ manifest,
10178
+ normalizedSessionId
10179
+ );
10180
+ if (nextManifest !== manifest) {
10181
+ await this.deps.writeManifest(nextManifest);
10182
+ }
10183
+ });
10184
+ }
10050
10185
  async runHasAssistantOutput(conversationId, runId) {
10051
10186
  const snapshot = await this.deps.readSnapshot(conversationId).catch(() => null);
10052
10187
  const run = snapshot?.runs.find((item) => item.id === runId);
@@ -10984,6 +11119,7 @@ var ConversationService = class {
10984
11119
  statsOverride
10985
11120
  ),
10986
11121
  readActiveManifest: (conversationId) => this.store.readActiveManifest(conversationId),
11122
+ writeManifest: (manifest) => this.store.writeManifest(manifest),
10987
11123
  isConversationActive: (conversationId) => this.store.isConversationActive(conversationId),
10988
11124
  writeBlob: (conversationId, input) => this.maintenance.writeBlob(conversationId, input),
10989
11125
  syncCronDeliveries: () => this.syncCronDeliveries(),
@@ -11089,6 +11225,7 @@ var ConversationService = class {
11089
11225
  title_source: isDefaultConversationTitle(title) ? "default" : "hermes",
11090
11226
  status: "active",
11091
11227
  hermes_session_id: `hp_${id}`,
11228
+ hermes_session_ids: [`hp_${id}`],
11092
11229
  profile_uid: profile.profileUid,
11093
11230
  profile_name_snapshot: profile.profileName,
11094
11231
  profile: profile.profileName,
@@ -12714,7 +12851,7 @@ function registerConversationRoutes(router, options) {
12714
12851
  ctx.set("cache-control", "private, max-age=86400");
12715
12852
  ctx.set(
12716
12853
  "content-disposition",
12717
- `inline; filename="${blob.filename.replaceAll('"', "")}"`
12854
+ contentDispositionInline(blob.filename)
12718
12855
  );
12719
12856
  ctx.body = blob.bytes;
12720
12857
  }
@@ -12733,6 +12870,34 @@ function registerConversationRoutes(router, options) {
12733
12870
  }
12734
12871
  );
12735
12872
  }
12873
+ function contentDispositionInline(filename) {
12874
+ const fallback = asciiFilenameFallback(filename);
12875
+ return `inline; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987Value(filename)}`;
12876
+ }
12877
+ function asciiFilenameFallback(filename) {
12878
+ const basename = filename.trim().split(/[\\/]/u).pop()?.trim() ?? "";
12879
+ const extension = safeAsciiExtension(basename);
12880
+ const stem = extension ? basename.slice(0, -extension.length) : basename;
12881
+ const asciiStem = stem.replace(/[^\x20-\x7E]/gu, "_").replace(/["\\]/gu, "_").replace(/[^A-Za-z0-9._ -]/gu, "_").replace(/_+/gu, "_").trim();
12882
+ if (/[A-Za-z0-9]/u.test(asciiStem)) {
12883
+ return `${asciiStem.slice(0, 120)}${extension}`;
12884
+ }
12885
+ return `attachment${extension}`;
12886
+ }
12887
+ function safeAsciiExtension(filename) {
12888
+ const dotIndex = filename.lastIndexOf(".");
12889
+ if (dotIndex < 0 || dotIndex === filename.length - 1) {
12890
+ return "";
12891
+ }
12892
+ const extension = filename.slice(dotIndex).toLowerCase();
12893
+ return /^\.[a-z0-9]{1,12}$/u.test(extension) ? extension : "";
12894
+ }
12895
+ function encodeRfc5987Value(value) {
12896
+ return encodeURIComponent(value).replace(
12897
+ /['()*]/gu,
12898
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
12899
+ );
12900
+ }
12736
12901
  function isConversationNotificationEvent(event) {
12737
12902
  const type = event.type.toLowerCase();
12738
12903
  return type === "conversation.created" || type === "conversation.updated" || type === "conversation.deleted" || type === "message.created" || type === "message.completed" || type === "message.failed" || type === "run.completed" || type === "run.failed" || type === "run.cancelled" || type === "run.canceled" || type === "approval.requested" || readPayloadBool(event.payload, "requires_action") || readPayloadBool(event.payload, "requires_user_action") || readPayloadBool(event.payload, "requires_approval");
@@ -14513,8 +14678,8 @@ import {
14513
14678
  import path18 from "path";
14514
14679
  import YAML4 from "yaml";
14515
14680
  var ENTRY_DELIMITER = "\n\xA7\n";
14516
- var MEMORY_LIMIT = 2200;
14517
- var USER_LIMIT = 1375;
14681
+ var DEFAULT_MEMORY_LIMIT = 2200;
14682
+ var DEFAULT_USER_LIMIT = 1375;
14518
14683
  var CUSTOM_PROVIDER_CARD_ID = "__custom__";
14519
14684
  var CUSTOM_PROVIDER_REGISTRY_FILE = "memory-providers.json";
14520
14685
  var HINDSIGHT_DEFAULT_API_URL = "https://api.hindsight.vectorize.io";
@@ -14597,9 +14762,10 @@ var HermesMemoryError = class extends Error {
14597
14762
  };
14598
14763
  async function readHermesProfileMemory(profileName = "default") {
14599
14764
  const memoryDir = resolveMemoryDir(profileName);
14765
+ const limits = await readMemoryLimits(profileName);
14600
14766
  const [memoryStore, userStore, settings] = await Promise.all([
14601
- readMemoryStore(profileName, "memory"),
14602
- readMemoryStore(profileName, "user"),
14767
+ readMemoryStore(profileName, "memory", limits),
14768
+ readMemoryStore(profileName, "user", limits),
14603
14769
  readMemorySettings(profileName)
14604
14770
  ]);
14605
14771
  return {
@@ -14617,9 +14783,7 @@ async function addHermesMemoryEntry(profileName, target, content) {
14617
14783
  if (entries.includes(normalized)) {
14618
14784
  return entries;
14619
14785
  }
14620
- const next = [...entries, normalized];
14621
- assertWithinLimit(target, next);
14622
- return next;
14786
+ return [...entries, normalized];
14623
14787
  });
14624
14788
  return readHermesProfileMemory(profileName);
14625
14789
  }
@@ -14630,7 +14794,6 @@ async function replaceHermesMemoryEntry(profileName, target, oldText, content) {
14630
14794
  const index = findSingleMatch(entries, needle);
14631
14795
  const next = [...entries];
14632
14796
  next[index] = normalized;
14633
- assertWithinLimit(target, next);
14634
14797
  return next;
14635
14798
  });
14636
14799
  return readHermesProfileMemory(profileName);
@@ -14903,7 +15066,7 @@ async function patchHermesMemoryProvider(profileName, provider) {
14903
15066
  function resolveMemoryDir(profileName) {
14904
15067
  return path18.join(resolveHermesProfileDir(profileName), "memories");
14905
15068
  }
14906
- async function readMemoryStore(profileName, target) {
15069
+ async function readMemoryStore(profileName, target, limits) {
14907
15070
  const filePath = memoryFilePath(profileName, target);
14908
15071
  const entries = await readMemoryEntries(filePath);
14909
15072
  const fileStat = await stat12(filePath).catch((error) => {
@@ -14913,7 +15076,7 @@ async function readMemoryStore(profileName, target) {
14913
15076
  throw error;
14914
15077
  });
14915
15078
  const chars = entries.length ? entries.join(ENTRY_DELIMITER).length : 0;
14916
- const limit = target === "user" ? USER_LIMIT : MEMORY_LIMIT;
15079
+ const limit = memoryLimitForTarget(limits, target);
14917
15080
  return {
14918
15081
  target,
14919
15082
  label: target === "user" ? "\u5173\u4E8E\u7528\u6237" : "Agent \u7B14\u8BB0",
@@ -14954,7 +15117,7 @@ async function mutateMemoryEntries(profileName, target, mutate) {
14954
15117
  await writeMemoryEntries(profileName, target, mutate([...new Set(current)]));
14955
15118
  }
14956
15119
  async function writeMemoryEntries(profileName, target, entries) {
14957
- assertWithinLimit(target, entries);
15120
+ assertWithinLimit(target, entries, await readMemoryLimits(profileName));
14958
15121
  const filePath = memoryFilePath(profileName, target);
14959
15122
  const dir = path18.dirname(filePath);
14960
15123
  await mkdir12(dir, { recursive: true, mode: 448 });
@@ -15767,8 +15930,28 @@ function selectSetting(key, label, value, options, editable = true) {
15767
15930
  const stringValue = readString13(value) ?? options[0] ?? null;
15768
15931
  return { key, label, value: stringValue, editable, kind: "select", options };
15769
15932
  }
15770
- function assertWithinLimit(target, entries) {
15771
- const limit = target === "user" ? USER_LIMIT : MEMORY_LIMIT;
15933
+ async function readMemoryLimits(profileName) {
15934
+ const raw = await readFile13(
15935
+ resolveHermesConfigPath(profileName),
15936
+ "utf8"
15937
+ ).catch((error) => {
15938
+ if (isNodeError13(error, "ENOENT")) {
15939
+ return "";
15940
+ }
15941
+ throw error;
15942
+ });
15943
+ const config = raw ? toRecord12(YAML4.parse(raw)) : {};
15944
+ const memory = toRecord12(config.memory);
15945
+ return {
15946
+ memory: readPositiveInteger3(memory.memory_char_limit) ?? DEFAULT_MEMORY_LIMIT,
15947
+ user: readPositiveInteger3(memory.user_char_limit) ?? DEFAULT_USER_LIMIT
15948
+ };
15949
+ }
15950
+ function memoryLimitForTarget(limits, target) {
15951
+ return target === "user" ? limits.user : limits.memory;
15952
+ }
15953
+ function assertWithinLimit(target, entries, limits) {
15954
+ const limit = memoryLimitForTarget(limits, target);
15772
15955
  const chars = entries.length ? entries.join(ENTRY_DELIMITER).length : 0;
15773
15956
  if (chars > limit) {
15774
15957
  throw new HermesMemoryError(
@@ -15820,6 +16003,10 @@ function toRecord12(value) {
15820
16003
  function readString13(value) {
15821
16004
  return typeof value === "string" && value.trim() ? value.trim() : null;
15822
16005
  }
16006
+ function readPositiveInteger3(value) {
16007
+ const numberValue = typeof value === "number" ? value : typeof value === "string" ? Number(value.trim()) : NaN;
16008
+ return Number.isFinite(numberValue) && numberValue > 0 ? Math.floor(numberValue) : void 0;
16009
+ }
15823
16010
  function readBoolean2(value) {
15824
16011
  if (typeof value === "boolean") {
15825
16012
  return value;
@@ -17495,6 +17682,7 @@ function connectRelayControl(options) {
17495
17682
  let retryTimer = null;
17496
17683
  let abortControllers = /* @__PURE__ */ new Map();
17497
17684
  let fatalRelayRejection = null;
17685
+ let latestNetworkRoutes = null;
17498
17686
  const connect = () => {
17499
17687
  options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
17500
17688
  fatalRelayRejection = null;
@@ -17506,6 +17694,10 @@ function connectRelayControl(options) {
17506
17694
  socket.on("open", () => {
17507
17695
  reconnectAttempts = 0;
17508
17696
  options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
17697
+ const currentSocket = socket;
17698
+ if (currentSocket && latestNetworkRoutes) {
17699
+ sendNetworkRoutes(currentSocket, options.linkId, latestNetworkRoutes);
17700
+ }
17509
17701
  });
17510
17702
  socket.on("message", (raw) => {
17511
17703
  if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
@@ -17553,6 +17745,12 @@ function connectRelayControl(options) {
17553
17745
  };
17554
17746
  connect();
17555
17747
  return {
17748
+ publishNetworkRoutes(routes) {
17749
+ latestNetworkRoutes = routes;
17750
+ if (socket?.readyState === WebSocket.OPEN) {
17751
+ sendNetworkRoutes(socket, options.linkId, routes);
17752
+ }
17753
+ },
17556
17754
  close() {
17557
17755
  closedByUser = true;
17558
17756
  if (retryTimer) {
@@ -17564,6 +17762,19 @@ function connectRelayControl(options) {
17564
17762
  }
17565
17763
  };
17566
17764
  }
17765
+ function sendNetworkRoutes(socket, linkId, routes) {
17766
+ socket.send(JSON.stringify({
17767
+ type: "network.routes",
17768
+ id: `routes_${Date.now().toString(36)}`,
17769
+ payload: {
17770
+ link_id: linkId,
17771
+ lan_ips: routes.lanIps,
17772
+ public_ipv4s: routes.publicIpv4s,
17773
+ public_ipv6s: routes.publicIpv6s,
17774
+ observed_at: (/* @__PURE__ */ new Date()).toISOString()
17775
+ }
17776
+ }));
17777
+ }
17567
17778
  function resolveFatalRelayRejection(message) {
17568
17779
  if (!/Unexpected server response:\s*(400|401|403|426)\b/u.test(message)) {
17569
17780
  return null;
@@ -17787,7 +17998,7 @@ async function discoverRouteCandidates(options) {
17787
17998
  const environment = detectRuntimeEnvironment();
17788
17999
  const configuredLanHost = normalizeLanHost(options.configuredLanHost);
17789
18000
  const lanIps = configuredLanHost ? [configuredLanHost] : environment.lanAutoDiscoveryUsable ? discoverLanIps() : [];
17790
- const publicIps = options.relayBootstrapToken ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
18001
+ const publicIps = options.relayBootstrapToken || options.observePublicRoute ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
17791
18002
  const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
17792
18003
  const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
17793
18004
  const preferredUrls = [
@@ -17830,8 +18041,8 @@ async function observePublicRoute(options) {
17830
18041
  const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
17831
18042
  method: "POST",
17832
18043
  headers: {
17833
- authorization: `Bearer ${options.relayBootstrapToken}`,
17834
- "content-type": "application/json"
18044
+ "content-type": "application/json",
18045
+ ...options.relayBootstrapToken ? { authorization: `Bearer ${options.relayBootstrapToken}` } : {}
17835
18046
  },
17836
18047
  body: JSON.stringify({
17837
18048
  install_id: options.installId,
@@ -17943,25 +18154,32 @@ function unique(values) {
17943
18154
 
17944
18155
  // src/link/network-report-state.ts
17945
18156
  var DEFAULT_AUTO_DAILY_LIMIT = 20;
17946
- async function markNetworkStatusReported(paths, lanIps, reportedAt = /* @__PURE__ */ new Date()) {
18157
+ async function markNetworkStatusReported(paths, snapshotInput, reportedAt = /* @__PURE__ */ new Date()) {
18158
+ const snapshot = normalizeNetworkSnapshot(snapshotInput);
17947
18159
  await updateNetworkReportState(paths, (current) => ({
17948
18160
  ...current,
17949
- lastReportedLanIps: normalizeLanIps(lanIps),
18161
+ lastReportedLanIps: snapshot.lanIps,
18162
+ lastReportedPublicIpv4s: snapshot.publicIpv4s,
18163
+ lastReportedPublicIpv6s: snapshot.publicIpv6s,
17950
18164
  lastReportedAt: reportedAt.toISOString(),
17951
- lastAutoAttempt: current.lastAutoAttempt ? { ...current.lastAutoAttempt, success: true } : null
18165
+ lastAutoAttempt: current.lastAutoAttempt ? { ...current.lastAutoAttempt, ...snapshot, success: true } : null
17952
18166
  }));
17953
18167
  }
17954
- async function reserveAutomaticNetworkReport(paths, lanIps, options = {}) {
17955
- const snapshot = normalizeLanIps(lanIps);
18168
+ async function reserveAutomaticNetworkReport(paths, snapshotInput, options = {}) {
18169
+ const snapshot = normalizeNetworkSnapshot(snapshotInput);
17956
18170
  const now = options.now ?? /* @__PURE__ */ new Date();
17957
18171
  const dailyLimit = Math.max(0, Math.floor(options.dailyLimit ?? DEFAULT_AUTO_DAILY_LIMIT));
17958
18172
  let reservation = { allowed: false, reason: "unchanged" };
17959
18173
  await updateNetworkReportState(paths, (current) => {
17960
- if (sameLanIps(current.lastReportedLanIps, snapshot)) {
17961
- reservation = { allowed: false, reason: "unchanged" };
17962
- return current;
18174
+ if (sameNetworkSnapshot(readReportedSnapshot(current), snapshot)) {
18175
+ const lastReportedAt = current.lastReportedAt ? Date.parse(current.lastReportedAt) : Number.NaN;
18176
+ const unchangedMinIntervalMs = Math.max(0, Math.floor(options.unchangedMinIntervalMs ?? 0));
18177
+ if (!options.force || Number.isFinite(lastReportedAt) && now.getTime() - lastReportedAt < unchangedMinIntervalMs) {
18178
+ reservation = { allowed: false, reason: "unchanged" };
18179
+ return current;
18180
+ }
17963
18181
  }
17964
- if (current.lastAutoAttempt && !current.lastAutoAttempt.success && sameLanIps(current.lastAutoAttempt.lanIps, snapshot)) {
18182
+ if (current.lastAutoAttempt && !current.lastAutoAttempt.success && sameNetworkSnapshot(readAttemptSnapshot(current.lastAutoAttempt), snapshot)) {
17965
18183
  reservation = { allowed: false, reason: "failed_snapshot_not_retried" };
17966
18184
  return current;
17967
18185
  }
@@ -17977,7 +18195,7 @@ async function reserveAutomaticNetworkReport(paths, lanIps, options = {}) {
17977
18195
  autoQuotaDay: quotaDay,
17978
18196
  autoReportsToday: reportsToday + 1,
17979
18197
  lastAutoAttempt: {
17980
- lanIps: snapshot,
18198
+ ...snapshot,
17981
18199
  attemptedAt: now.toISOString(),
17982
18200
  success: false
17983
18201
  }
@@ -18001,6 +18219,8 @@ function normalizeNetworkReportState(value) {
18001
18219
  const record = value && typeof value === "object" ? value : {};
18002
18220
  return {
18003
18221
  lastReportedLanIps: normalizeLanIps(record.lastReportedLanIps),
18222
+ lastReportedPublicIpv4s: normalizeLanIps(record.lastReportedPublicIpv4s),
18223
+ lastReportedPublicIpv6s: normalizeLanIps(record.lastReportedPublicIpv6s),
18004
18224
  lastReportedAt: typeof record.lastReportedAt === "string" ? record.lastReportedAt : null,
18005
18225
  autoQuotaDay: typeof record.autoQuotaDay === "string" ? record.autoQuotaDay : null,
18006
18226
  autoReportsToday: Number.isFinite(record.autoReportsToday) ? Math.max(0, Math.floor(record.autoReportsToday ?? 0)) : 0,
@@ -18017,10 +18237,41 @@ function normalizeAttempt(value) {
18017
18237
  }
18018
18238
  return {
18019
18239
  lanIps: normalizeLanIps(record.lanIps),
18240
+ publicIpv4s: normalizeLanIps(record.publicIpv4s),
18241
+ publicIpv6s: normalizeLanIps(record.publicIpv6s),
18020
18242
  attemptedAt: record.attemptedAt,
18021
18243
  success: record.success === true
18022
18244
  };
18023
18245
  }
18246
+ function normalizeNetworkSnapshot(value) {
18247
+ if (Array.isArray(value)) {
18248
+ return {
18249
+ lanIps: normalizeLanIps(value),
18250
+ publicIpv4s: [],
18251
+ publicIpv6s: []
18252
+ };
18253
+ }
18254
+ const record = value && typeof value === "object" ? value : {};
18255
+ return {
18256
+ lanIps: normalizeLanIps(record.lanIps),
18257
+ publicIpv4s: normalizeLanIps(record.publicIpv4s),
18258
+ publicIpv6s: normalizeLanIps(record.publicIpv6s)
18259
+ };
18260
+ }
18261
+ function readReportedSnapshot(state) {
18262
+ return {
18263
+ lanIps: state.lastReportedLanIps,
18264
+ publicIpv4s: state.lastReportedPublicIpv4s,
18265
+ publicIpv6s: state.lastReportedPublicIpv6s
18266
+ };
18267
+ }
18268
+ function readAttemptSnapshot(attempt) {
18269
+ return {
18270
+ lanIps: attempt.lanIps,
18271
+ publicIpv4s: attempt.publicIpv4s,
18272
+ publicIpv6s: attempt.publicIpv6s
18273
+ };
18274
+ }
18024
18275
  function normalizeLanIps(value) {
18025
18276
  if (!Array.isArray(value)) {
18026
18277
  return [];
@@ -18031,7 +18282,10 @@ function normalizeLanIps(value) {
18031
18282
  )
18032
18283
  ];
18033
18284
  }
18034
- function sameLanIps(left, right) {
18285
+ function sameNetworkSnapshot(left, right) {
18286
+ return sameStringList(left.lanIps, right.lanIps) && sameStringList(left.publicIpv4s, right.publicIpv4s) && sameStringList(left.publicIpv6s, right.publicIpv6s);
18287
+ }
18288
+ function sameStringList(left, right) {
18035
18289
  if (left.length !== right.length) {
18036
18290
  return false;
18037
18291
  }
@@ -18048,12 +18302,13 @@ async function reportLinkStatusToServer(options = {}) {
18048
18302
  if (!identity?.link_id) {
18049
18303
  return null;
18050
18304
  }
18051
- const routes = await discoverRouteCandidates({
18305
+ const routes = options.routes ?? await discoverRouteCandidates({
18052
18306
  port: config.port,
18053
18307
  relayBaseUrl: config.relayBaseUrl,
18054
18308
  linkId: identity.link_id,
18055
18309
  installId: identity.install_id,
18056
18310
  publicKeyPem: identity.public_key_pem,
18311
+ observePublicRoute: true,
18057
18312
  configuredLanHost: config.lanHost,
18058
18313
  fetchImpl: options.fetchImpl
18059
18314
  });
@@ -18093,7 +18348,7 @@ async function reportLinkStatusToServer(options = {}) {
18093
18348
  const message = readErrorMessage3(body) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
18094
18349
  throw new LinkHttpError(response.status, "server_request_failed", message);
18095
18350
  }
18096
- await markNetworkStatusReported(paths, routes.lanIps);
18351
+ await markNetworkStatusReported(paths, routes);
18097
18352
  return body;
18098
18353
  }
18099
18354
  function canonicalJson(value) {
@@ -18128,16 +18383,17 @@ function readErrorMessage3(payload) {
18128
18383
  // src/daemon/lan-ip-monitor.ts
18129
18384
  var DEFAULT_INTERVAL_MS = 5 * 6e4;
18130
18385
  var DEFAULT_DAILY_REPORT_LIMIT = 20;
18386
+ var DEFAULT_STARTUP_REPORT_MIN_INTERVAL_MS = 15 * 6e4;
18131
18387
  function startLanIpMonitor(options) {
18132
18388
  let running = false;
18133
18389
  let closed = false;
18134
- const check = async () => {
18390
+ const check = async (context = {}) => {
18135
18391
  if (running || closed) {
18136
18392
  return;
18137
18393
  }
18138
18394
  running = true;
18139
18395
  try {
18140
- await checkLanIpChange(options);
18396
+ await checkLanIpChange(options, context);
18141
18397
  } catch (error) {
18142
18398
  void options.logger.warn("lan_ip_monitor_failed", {
18143
18399
  error: error instanceof Error ? error.message : String(error)
@@ -18146,7 +18402,7 @@ function startLanIpMonitor(options) {
18146
18402
  running = false;
18147
18403
  }
18148
18404
  };
18149
- void check();
18405
+ void check({ forceReport: true, publishToRelay: true });
18150
18406
  const timer = setInterval(() => {
18151
18407
  void check();
18152
18408
  }, options.intervalMs ?? DEFAULT_INTERVAL_MS);
@@ -18158,7 +18414,7 @@ function startLanIpMonitor(options) {
18158
18414
  }
18159
18415
  };
18160
18416
  }
18161
- async function checkLanIpChange(options) {
18417
+ async function checkLanIpChange(options, context = {}) {
18162
18418
  const [identity, config] = await Promise.all([
18163
18419
  loadIdentity(options.paths),
18164
18420
  loadConfig(options.paths)
@@ -18172,14 +18428,25 @@ async function checkLanIpChange(options) {
18172
18428
  linkId: identity.link_id,
18173
18429
  installId: identity.install_id,
18174
18430
  publicKeyPem: identity.public_key_pem,
18431
+ observePublicRoute: true,
18175
18432
  configuredLanHost: config.lanHost,
18176
18433
  fetchImpl: options.fetchImpl
18177
18434
  });
18178
- const reservation = await reserveAutomaticNetworkReport(options.paths, routes.lanIps, {
18179
- dailyLimit: options.dailyReportLimit ?? DEFAULT_DAILY_REPORT_LIMIT
18435
+ if (context.publishToRelay) {
18436
+ options.onNetworkRoutes?.(routes);
18437
+ }
18438
+ const reservation = await reserveAutomaticNetworkReport(options.paths, routes, {
18439
+ dailyLimit: options.dailyReportLimit ?? DEFAULT_DAILY_REPORT_LIMIT,
18440
+ force: context.forceReport === true,
18441
+ unchangedMinIntervalMs: options.startupReportMinIntervalMs ?? DEFAULT_STARTUP_REPORT_MIN_INTERVAL_MS
18180
18442
  });
18181
18443
  if (!reservation.allowed) {
18182
- const logFields = { lan_ips: routes.lanIps, reason: reservation.reason };
18444
+ const logFields = {
18445
+ lan_ips: routes.lanIps,
18446
+ public_ipv4s: routes.publicIpv4s,
18447
+ public_ipv6s: routes.publicIpv6s,
18448
+ reason: reservation.reason
18449
+ };
18183
18450
  if (reservation.reason === "daily_limit_reached") {
18184
18451
  void options.logger.warn("lan_ip_report_skipped", logFields);
18185
18452
  } else {
@@ -18190,12 +18457,16 @@ async function checkLanIpChange(options) {
18190
18457
  try {
18191
18458
  const result = await reportLinkStatusToServer({
18192
18459
  paths: options.paths,
18193
- fetchImpl: options.fetchImpl
18460
+ fetchImpl: options.fetchImpl,
18461
+ routes
18194
18462
  });
18195
18463
  if (result) {
18464
+ options.onNetworkRoutes?.(routes);
18196
18465
  void options.logger.info("lan_ip_change_reported", {
18197
18466
  link_id: result.linkId,
18198
- lan_ips: routes.lanIps
18467
+ lan_ips: routes.lanIps,
18468
+ public_ipv4s: routes.publicIpv4s,
18469
+ public_ipv6s: routes.publicIpv6s
18199
18470
  });
18200
18471
  }
18201
18472
  } catch (error) {
@@ -18287,13 +18558,6 @@ async function startLinkService(options = {}) {
18287
18558
  conversations,
18288
18559
  logger
18289
18560
  });
18290
- const lanIpMonitor = startLanIpMonitor({
18291
- paths,
18292
- logger,
18293
- intervalMs: options.lanIpMonitorIntervalMs,
18294
- dailyReportLimit: options.lanIpMonitorDailyReportLimit,
18295
- fetchImpl: options.lanIpMonitorFetchImpl
18296
- });
18297
18561
  let relay = null;
18298
18562
  if (identity?.link_id) {
18299
18563
  relay = connectRelayControl({
@@ -18310,6 +18574,16 @@ async function startLinkService(options = {}) {
18310
18574
  } else {
18311
18575
  void logger.info("relay_skipped", { reason: "link_not_paired" });
18312
18576
  }
18577
+ const lanIpMonitor = startLanIpMonitor({
18578
+ paths,
18579
+ logger,
18580
+ intervalMs: options.lanIpMonitorIntervalMs,
18581
+ dailyReportLimit: options.lanIpMonitorDailyReportLimit,
18582
+ fetchImpl: options.lanIpMonitorFetchImpl,
18583
+ onNetworkRoutes: (routes) => {
18584
+ relay?.publishNetworkRoutes(routes);
18585
+ }
18586
+ });
18313
18587
  if (options.writePidFile) {
18314
18588
  await writePidFile(paths);
18315
18589
  }
package/dist/cli/index.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  startDaemonProcess,
31
31
  startLinkService,
32
32
  stopDaemonProcess
33
- } from "../chunk-PSHYSZAP.js";
33
+ } from "../chunk-IJTQ6YVR.js";
34
34
 
35
35
  // src/cli/index.ts
36
36
  import { Command } from "commander";
@@ -237,12 +237,14 @@ interface ConversationEvent {
237
237
  interface DeleteConversationResult {
238
238
  conversation_id: string;
239
239
  hermes_deleted: boolean;
240
+ hermes_session_ids?: string[];
240
241
  deleted_at: string;
241
242
  }
242
243
  interface BulkDeleteConversationResult {
243
244
  conversation_id: string;
244
245
  status: 'deleted' | 'failed';
245
246
  hermes_deleted?: boolean;
247
+ hermes_session_ids?: string[];
246
248
  deleted_at?: string;
247
249
  error?: {
248
250
  code: string;
package/dist/http/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-PSHYSZAP.js";
3
+ } from "../chunk-IJTQ6YVR.js";
4
4
  export {
5
5
  createApp
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hermespilot/link",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "private": false,
5
5
  "description": "Hermes Link companion service and CLI for connecting hermes-agent through HermesPilot",
6
6
  "license": "MIT",